sandbox connected and streaming
This commit is contained in:
11
gateway/src/tools/index.ts
Normal file
11
gateway/src/tools/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Tools exports
|
||||
|
||||
export * from './platform/index.js';
|
||||
export * from './mcp/index.js';
|
||||
export {
|
||||
ToolRegistry,
|
||||
initializeToolRegistry,
|
||||
getToolRegistry,
|
||||
type AgentToolConfig,
|
||||
type PlatformServices,
|
||||
} from './tool-registry.js';
|
||||
7
gateway/src/tools/mcp/index.ts
Normal file
7
gateway/src/tools/mcp/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// MCP tool wrappers exports
|
||||
|
||||
export {
|
||||
createMCPToolWrapper,
|
||||
createMCPToolWrappers,
|
||||
type MCPToolInfo,
|
||||
} from './mcp-tool-wrapper.js';
|
||||
186
gateway/src/tools/mcp/mcp-tool-wrapper.ts
Normal file
186
gateway/src/tools/mcp/mcp-tool-wrapper.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { MCPClientConnector } from '../../harness/mcp-client.js';
|
||||
|
||||
/**
|
||||
* MCP Tool Wrapper
|
||||
*
|
||||
* Wraps remote MCP server tools as standard LangChain tools.
|
||||
* Provides dynamic tool creation based on MCP tool definitions.
|
||||
*/
|
||||
|
||||
export interface MCPToolInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: {
|
||||
type: string;
|
||||
properties?: Record<string, any>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a LangChain tool from an MCP tool definition
|
||||
*/
|
||||
export function createMCPToolWrapper(
|
||||
toolInfo: MCPToolInfo,
|
||||
mcpClient: MCPClientConnector,
|
||||
logger: FastifyBaseLogger,
|
||||
onImage?: (image: { data: string; mimeType: string }) => void
|
||||
): DynamicStructuredTool {
|
||||
// Convert MCP input schema to Zod schema
|
||||
const zodSchema = mcpInputSchemaToZod(toolInfo.inputSchema);
|
||||
|
||||
return new DynamicStructuredTool({
|
||||
name: toolInfo.name,
|
||||
description: toolInfo.description || `MCP tool: ${toolInfo.name}`,
|
||||
schema: zodSchema,
|
||||
func: async (input: Record<string, unknown>) => {
|
||||
try {
|
||||
const result = await mcpClient.callTool(toolInfo.name, input);
|
||||
|
||||
logger.info({ tool: toolInfo.name }, 'MCP tool call completed');
|
||||
|
||||
// Handle different MCP result formats
|
||||
if (typeof result === 'string') {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Handle structured MCP responses with content arrays
|
||||
if (result && typeof result === 'object') {
|
||||
// Extract text content from MCP response
|
||||
const textParts: string[] = [];
|
||||
|
||||
// Check for content array (standard MCP format)
|
||||
if (Array.isArray((result as any).content)) {
|
||||
logger.debug({ tool: toolInfo.name, itemCount: (result as any).content.length }, 'Processing MCP content array');
|
||||
for (const item of (result as any).content) {
|
||||
if (item.type === 'text' && item.text) {
|
||||
textParts.push(item.text);
|
||||
} else if (item.type === 'image' && item.data && item.mimeType) {
|
||||
logger.info({ tool: toolInfo.name, mimeType: item.mimeType }, 'Capturing image from MCP response');
|
||||
onImage?.({ data: item.data, mimeType: item.mimeType });
|
||||
}
|
||||
}
|
||||
if (textParts.length > 0) {
|
||||
return textParts.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for nested execution.content
|
||||
if ((result as any).execution && Array.isArray((result as any).execution.content)) {
|
||||
for (const item of (result as any).execution.content) {
|
||||
if (item.type === 'text' && item.text) {
|
||||
textParts.push(item.text);
|
||||
} else if (item.type === 'image' && item.data && item.mimeType) {
|
||||
onImage?.({ data: item.data, mimeType: item.mimeType });
|
||||
}
|
||||
}
|
||||
if (textParts.length > 0) {
|
||||
return textParts.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: stringify the result
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
return String(result || '');
|
||||
} catch (error) {
|
||||
logger.error({ error, tool: toolInfo.name, input }, 'MCP tool call failed');
|
||||
return `Error calling MCP tool ${toolInfo.name}: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MCP input schema to Zod schema
|
||||
*/
|
||||
function mcpInputSchemaToZod(inputSchema?: MCPToolInfo['inputSchema']): z.ZodObject<any> {
|
||||
if (!inputSchema || !inputSchema.properties) {
|
||||
// Generic schema that accepts any properties
|
||||
return z.object({}).passthrough();
|
||||
}
|
||||
|
||||
const properties = inputSchema.properties;
|
||||
const required = inputSchema.required || [];
|
||||
|
||||
const zodFields: Record<string, z.ZodTypeAny> = {};
|
||||
|
||||
for (const [key, prop] of Object.entries(properties)) {
|
||||
let zodType: z.ZodTypeAny;
|
||||
|
||||
// Map JSON Schema types to Zod types
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
zodType = z.string().describe(prop.description || '');
|
||||
break;
|
||||
case 'number':
|
||||
zodType = z.number().describe(prop.description || '');
|
||||
break;
|
||||
case 'integer':
|
||||
zodType = z.number().int().describe(prop.description || '');
|
||||
break;
|
||||
case 'boolean':
|
||||
zodType = z.boolean().describe(prop.description || '');
|
||||
break;
|
||||
case 'array':
|
||||
// Handle array items
|
||||
if (prop.items) {
|
||||
const itemType = getZodTypeForProperty(prop.items);
|
||||
zodType = z.array(itemType).describe(prop.description || '');
|
||||
} else {
|
||||
zodType = z.array(z.any()).describe(prop.description || '');
|
||||
}
|
||||
break;
|
||||
case 'object':
|
||||
zodType = z.object({}).passthrough().describe(prop.description || '');
|
||||
break;
|
||||
default:
|
||||
zodType = z.any().describe(prop.description || '');
|
||||
}
|
||||
|
||||
// Make optional if not required
|
||||
if (!required.includes(key)) {
|
||||
zodType = zodType.optional();
|
||||
}
|
||||
|
||||
zodFields[key] = zodType;
|
||||
}
|
||||
|
||||
return z.object(zodFields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get Zod type for a property definition
|
||||
*/
|
||||
function getZodTypeForProperty(prop: any): z.ZodTypeAny {
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
return z.string();
|
||||
case 'number':
|
||||
return z.number();
|
||||
case 'integer':
|
||||
return z.number().int();
|
||||
case 'boolean':
|
||||
return z.boolean();
|
||||
case 'object':
|
||||
return z.object({}).passthrough();
|
||||
default:
|
||||
return z.any();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple MCP tool wrappers from tool list
|
||||
*/
|
||||
export function createMCPToolWrappers(
|
||||
toolInfos: MCPToolInfo[],
|
||||
mcpClient: MCPClientConnector,
|
||||
logger: FastifyBaseLogger,
|
||||
onImage?: (image: { data: string; mimeType: string }) => void
|
||||
): DynamicStructuredTool[] {
|
||||
return toolInfos.map(toolInfo => createMCPToolWrapper(toolInfo, mcpClient, logger, onImage));
|
||||
}
|
||||
253
gateway/src/tools/platform/get-chart-data.tool.ts
Normal file
253
gateway/src/tools/platform/get-chart-data.tool.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { OHLCService } from '../../services/ohlc-service.js';
|
||||
import type { WorkspaceManager } from '../../workspace/workspace-manager.js';
|
||||
import type { ChartState } from '../../workspace/types.js';
|
||||
import * as chrono from 'chrono-node';
|
||||
|
||||
/**
|
||||
* Get Chart Data Tool
|
||||
*
|
||||
* Standard LangChain tool for fetching OHLCV+ data with workspace defaults.
|
||||
* Allows agent to override any parameter for historical or alternative ticker queries.
|
||||
*/
|
||||
|
||||
export interface GetChartDataToolConfig {
|
||||
ohlcService: OHLCService;
|
||||
workspaceManager: WorkspaceManager;
|
||||
logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
export function createGetChartDataTool(config: GetChartDataToolConfig): DynamicStructuredTool {
|
||||
const { ohlcService, workspaceManager, logger } = config;
|
||||
|
||||
return new DynamicStructuredTool({
|
||||
name: 'get_chart_data',
|
||||
description: `Fetch OHLCV+ data for current chart or any ticker/timeframe. All parameters are optional and default to workspace chart state.
|
||||
|
||||
**IMPORTANT: Use this tool ONLY for quick, casual data viewing. For any analysis, plotting, statistics, or deep research, use the 'research' tool instead.**
|
||||
|
||||
Parameters:
|
||||
- ticker (optional): Market symbol (defaults to workspace chartState.symbol)
|
||||
- period (optional): OHLC period in seconds (defaults to workspace chartState.period)
|
||||
- from_time (optional): Start time as Unix timestamp (number or string like "1774126800") OR date string like "2 days ago", "2024-01-01" (defaults to workspace chartState.start_time)
|
||||
- to_time (optional): End time as Unix timestamp (number or string like "1774732500") OR date string like "now", "yesterday" (defaults to workspace chartState.end_time)
|
||||
- countback (optional): Limit number of bars returned
|
||||
- columns (optional): Extra columns beyond OHLC: ["volume", "buy_vol", "sell_vol", "open_time", "high_time", "low_time", "close_time", "open_interest"]`,
|
||||
schema: z.object({
|
||||
ticker: z.string().optional().describe('Market symbol (defaults to workspace chartState.symbol)'),
|
||||
period: z.number().optional().describe('OHLC period in seconds (defaults to workspace chartState.period)'),
|
||||
from_time: z.union([z.number(), z.string()]).optional().describe('Start time: Unix seconds OR date string (defaults to workspace chartState.start_time)'),
|
||||
to_time: z.union([z.number(), z.string()]).optional().describe('End time: Unix seconds OR date string (defaults to workspace chartState.end_time)'),
|
||||
countback: z.number().optional().describe('Limit number of bars returned'),
|
||||
columns: z.array(z.enum(['volume', 'buy_vol', 'sell_vol', 'open_time', 'high_time', 'low_time', 'close_time', 'open_interest'])).optional().describe('Extra columns beyond OHLC'),
|
||||
}),
|
||||
func: async ({ ticker, period, from_time, to_time, countback, columns }) => {
|
||||
logger.debug({ ticker, period, from_time, to_time, countback, columns }, 'Executing get_chart_data tool');
|
||||
|
||||
try {
|
||||
// Get workspace chart state
|
||||
const chartState = await getChartState(workspaceManager, logger);
|
||||
|
||||
// Build request with workspace defaults
|
||||
const finalTicker = ticker ?? chartState.symbol;
|
||||
const finalPeriod = period ?? parsePeriod(chartState.period);
|
||||
const finalFromTime = await parseTime(from_time, chartState.start_time, logger);
|
||||
const finalToTime = await parseTime(to_time, chartState.end_time, logger);
|
||||
const requestedColumns = columns ?? [];
|
||||
|
||||
// Validate we have all required parameters
|
||||
if (!finalTicker) {
|
||||
return JSON.stringify({ error: 'Ticker not specified and not available in workspace' });
|
||||
}
|
||||
if (!finalPeriod) {
|
||||
return JSON.stringify({ error: 'Period not specified and not available in workspace' });
|
||||
}
|
||||
if (!finalFromTime) {
|
||||
return JSON.stringify({ error: 'from_time not specified and not available in workspace' });
|
||||
}
|
||||
if (!finalToTime) {
|
||||
return JSON.stringify({ error: 'to_time not specified and not available in workspace' });
|
||||
}
|
||||
|
||||
logger.debug({
|
||||
ticker: finalTicker,
|
||||
period: finalPeriod,
|
||||
from_time: finalFromTime,
|
||||
to_time: finalToTime,
|
||||
countback,
|
||||
columns: requestedColumns,
|
||||
}, 'Fetching OHLC data');
|
||||
|
||||
// Fetch data from OHLCService
|
||||
const historyResult = await ohlcService.fetchOHLC(
|
||||
finalTicker,
|
||||
finalPeriod.toString(),
|
||||
finalFromTime,
|
||||
finalToTime,
|
||||
countback
|
||||
);
|
||||
|
||||
if (historyResult.noData || !historyResult.bars || historyResult.bars.length === 0) {
|
||||
return JSON.stringify({
|
||||
ticker: finalTicker,
|
||||
period: finalPeriod,
|
||||
timeRange: { start: finalFromTime, end: finalToTime },
|
||||
bars: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Filter/format bars with requested columns
|
||||
const bars = historyResult.bars.map(bar => {
|
||||
const result: any = {
|
||||
time: bar.time,
|
||||
open: bar.open,
|
||||
high: bar.high,
|
||||
low: bar.low,
|
||||
close: bar.close,
|
||||
ticker: finalTicker,
|
||||
};
|
||||
|
||||
// Add optional columns if requested
|
||||
for (const col of requestedColumns) {
|
||||
if (col === 'volume' && bar.volume !== undefined) {
|
||||
result.volume = bar.volume;
|
||||
} else if (col === 'buy_vol' && bar.buy_vol !== undefined) {
|
||||
result.buy_vol = bar.buy_vol;
|
||||
} else if (col === 'sell_vol' && bar.sell_vol !== undefined) {
|
||||
result.sell_vol = bar.sell_vol;
|
||||
} else if (col === 'open_time' && bar.open_time !== undefined) {
|
||||
result.open_time = bar.open_time;
|
||||
} else if (col === 'high_time' && bar.high_time !== undefined) {
|
||||
result.high_time = bar.high_time;
|
||||
} else if (col === 'low_time' && bar.low_time !== undefined) {
|
||||
result.low_time = bar.low_time;
|
||||
} else if (col === 'close_time' && bar.close_time !== undefined) {
|
||||
result.close_time = bar.close_time;
|
||||
} else if (col === 'open_interest' && bar.open_interest !== undefined) {
|
||||
result.open_interest = bar.open_interest;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
logger.info({ ticker: finalTicker, barCount: bars.length }, 'Chart data fetched successfully');
|
||||
|
||||
return JSON.stringify({
|
||||
ticker: finalTicker,
|
||||
period: finalPeriod,
|
||||
timeRange: {
|
||||
start: finalFromTime,
|
||||
end: finalToTime,
|
||||
},
|
||||
bars,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Get chart data tool failed');
|
||||
return JSON.stringify({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chart state from workspace
|
||||
*/
|
||||
async function getChartState(workspaceManager: WorkspaceManager, logger: FastifyBaseLogger): Promise<ChartState> {
|
||||
try {
|
||||
const chartState = workspaceManager.getState<ChartState>('chartState');
|
||||
|
||||
if (!chartState) {
|
||||
// Return default chart state
|
||||
return {
|
||||
symbol: 'BINANCE:BTC/USDT',
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
period: '15',
|
||||
selected_shapes: [],
|
||||
};
|
||||
}
|
||||
|
||||
return chartState;
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to get chart state from workspace');
|
||||
// Return default chart state
|
||||
return {
|
||||
symbol: 'BINANCE:BTC/USDT',
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
period: '15',
|
||||
selected_shapes: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse period string to seconds
|
||||
* Handles period as either a number (already in seconds) or string (minutes)
|
||||
*/
|
||||
function parsePeriod(period: string | number | null): number | null {
|
||||
if (period === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof period === 'number') {
|
||||
return period;
|
||||
}
|
||||
|
||||
// Period in workspace is stored as string representing minutes
|
||||
// Convert to seconds
|
||||
const minutes = parseInt(period, 10);
|
||||
if (isNaN(minutes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return minutes * 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse time parameter (Unix seconds, date string, or null)
|
||||
* Returns Unix timestamp in seconds
|
||||
*/
|
||||
async function parseTime(
|
||||
timeParam: number | string | null | undefined,
|
||||
workspaceDefault: number | null,
|
||||
logger: FastifyBaseLogger
|
||||
): Promise<number | null> {
|
||||
// Use workspace default if param not provided
|
||||
if (timeParam === undefined || timeParam === null) {
|
||||
return workspaceDefault;
|
||||
}
|
||||
|
||||
// If it's already a number, assume Unix seconds
|
||||
if (typeof timeParam === 'number') {
|
||||
return timeParam;
|
||||
}
|
||||
|
||||
// Try to parse string as numeric Unix timestamp first
|
||||
const numericTimestamp = parseInt(timeParam, 10);
|
||||
if (!isNaN(numericTimestamp) && numericTimestamp.toString() === timeParam) {
|
||||
// String is a valid integer - treat as Unix seconds
|
||||
logger.debug({ timeParam, parsedTimestamp: numericTimestamp }, 'Parsed string as Unix timestamp');
|
||||
return numericTimestamp;
|
||||
}
|
||||
|
||||
// Parse date string using chrono
|
||||
try {
|
||||
const parsed = chrono.parseDate(timeParam);
|
||||
if (!parsed) {
|
||||
logger.warn({ timeParam }, 'Failed to parse time string');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert to Unix seconds
|
||||
return Math.floor(parsed.getTime() / 1000);
|
||||
} catch (error) {
|
||||
logger.error({ error, timeParam }, 'Error parsing time string');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
11
gateway/src/tools/platform/index.ts
Normal file
11
gateway/src/tools/platform/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Platform tools exports
|
||||
|
||||
export {
|
||||
createSymbolLookupTool,
|
||||
type SymbolLookupToolConfig,
|
||||
} from './symbol-lookup.tool.js';
|
||||
|
||||
export {
|
||||
createGetChartDataTool,
|
||||
type GetChartDataToolConfig,
|
||||
} from './get-chart-data.tool.js';
|
||||
53
gateway/src/tools/platform/research-agent.tool.ts
Normal file
53
gateway/src/tools/platform/research-agent.tool.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { ResearchSubagent } from '../../harness/subagents/research/index.js';
|
||||
import type { SubagentContext } from '../../harness/subagents/base-subagent.js';
|
||||
|
||||
export interface ResearchAgentToolConfig {
|
||||
researchSubagent: ResearchSubagent;
|
||||
context: SubagentContext;
|
||||
logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a LangChain tool that delegates to the research subagent.
|
||||
* This is the standard LangChain pattern for exposing a subagent as a tool
|
||||
* to a parent agent.
|
||||
*/
|
||||
export function createResearchAgentTool(config: ResearchAgentToolConfig): DynamicStructuredTool {
|
||||
const { researchSubagent, context, logger } = config;
|
||||
|
||||
return new DynamicStructuredTool({
|
||||
name: 'research',
|
||||
description: `Delegate to the research subagent for data analysis, charting, statistics, and Python script execution.
|
||||
|
||||
Use this tool for:
|
||||
- Plotting charts with technical indicators (EMA, RSI, MACD, Bollinger Bands, etc.)
|
||||
- Statistical analysis of price data
|
||||
- Custom research scripts using the DataAPI and ChartingAPI
|
||||
- Any task requiring code execution or matplotlib charts
|
||||
|
||||
The research subagent will write and execute Python scripts, capture output and charts, and return results.`,
|
||||
schema: z.object({
|
||||
instruction: z.string().describe('The research task or analysis to perform. Be specific about what data, indicators, timeframes, and output you want.'),
|
||||
}),
|
||||
func: async ({ instruction }: { instruction: string }): Promise<string> => {
|
||||
logger.info({ instruction: instruction.substring(0, 100) }, 'Delegating to research subagent');
|
||||
|
||||
try {
|
||||
const result = await researchSubagent.executeWithImages(context, instruction);
|
||||
|
||||
// Return in the format that AgentHarness.processToolResult() knows how to handle
|
||||
// (extracts images and passes them to channelAdapter)
|
||||
return JSON.stringify({
|
||||
text: result.text,
|
||||
images: result.images,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, errorMessage: (error as Error)?.message }, 'Research subagent failed');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
78
gateway/src/tools/platform/symbol-lookup.tool.ts
Normal file
78
gateway/src/tools/platform/symbol-lookup.tool.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { SymbolIndexService } from '../../services/symbol-index-service.js';
|
||||
|
||||
/**
|
||||
* Symbol Lookup Tool
|
||||
*
|
||||
* Standard LangChain tool for symbol search and resolution.
|
||||
* Supports two modes:
|
||||
* - search: Find symbols matching a query
|
||||
* - resolve: Get detailed metadata for a specific symbol
|
||||
*/
|
||||
|
||||
export interface SymbolLookupToolConfig {
|
||||
symbolIndexService: SymbolIndexService;
|
||||
logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
export function createSymbolLookupTool(config: SymbolLookupToolConfig): DynamicStructuredTool {
|
||||
const { symbolIndexService, logger } = config;
|
||||
|
||||
return new DynamicStructuredTool({
|
||||
name: 'symbol_lookup',
|
||||
description: `Search for market symbols or resolve symbol metadata. Use 'search' mode to find symbols matching a query, or 'resolve' mode to get detailed metadata for a specific symbol.
|
||||
|
||||
Parameters:
|
||||
- mode (required): Either 'search' or 'resolve'
|
||||
- query (required): Search query (for search mode) or symbol ticker (for resolve mode)
|
||||
- limit (optional): Maximum number of search results (search mode only, default: 30)`,
|
||||
schema: z.object({
|
||||
mode: z.enum(['search', 'resolve']).describe('Operation mode: search for symbols or resolve a specific symbol'),
|
||||
query: z.string().describe('Search query (for search mode) or symbol ticker (for resolve mode)'),
|
||||
limit: z.number().optional().default(30).describe('Maximum number of search results (search mode only, default: 30)'),
|
||||
}),
|
||||
func: async ({ mode, query, limit }) => {
|
||||
logger.debug({ mode, query, limit }, 'Executing symbol_lookup tool');
|
||||
|
||||
try {
|
||||
if (mode === 'search') {
|
||||
const results = await symbolIndexService.search(query, limit);
|
||||
|
||||
logger.info({ query, resultCount: results.length }, 'Symbol search completed');
|
||||
|
||||
return JSON.stringify({
|
||||
mode: 'search',
|
||||
query,
|
||||
count: results.length,
|
||||
results,
|
||||
});
|
||||
} else {
|
||||
const symbolInfo = await symbolIndexService.resolveSymbol(query);
|
||||
|
||||
if (!symbolInfo) {
|
||||
logger.warn({ symbol: query }, 'Symbol not found');
|
||||
return JSON.stringify({
|
||||
error: `Symbol not found: ${query}`,
|
||||
symbol: query,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({ symbol: query }, 'Symbol resolved');
|
||||
|
||||
return JSON.stringify({
|
||||
mode: 'resolve',
|
||||
symbol: query,
|
||||
symbolInfo,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, mode, query }, 'Symbol lookup tool failed');
|
||||
return JSON.stringify({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
291
gateway/src/tools/tool-registry.ts
Normal file
291
gateway/src/tools/tool-registry.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { MCPClientConnector } from '../harness/mcp-client.js';
|
||||
import type { OHLCService } from '../services/ohlc-service.js';
|
||||
import type { SymbolIndexService } from '../services/symbol-index-service.js';
|
||||
import type { WorkspaceManager } from '../workspace/workspace-manager.js';
|
||||
import { createSymbolLookupTool } from './platform/symbol-lookup.tool.js';
|
||||
import { createGetChartDataTool } from './platform/get-chart-data.tool.js';
|
||||
import { createMCPToolWrappers, type MCPToolInfo } from './mcp/mcp-tool-wrapper.js';
|
||||
|
||||
/**
|
||||
* Agent tool configuration
|
||||
* Specifies which tools are available to which agent
|
||||
*/
|
||||
export interface AgentToolConfig {
|
||||
/** Agent name (e.g., 'main', 'research', 'code-reviewer') */
|
||||
agentName: string;
|
||||
|
||||
/** Platform tool names to include */
|
||||
platformTools: string[];
|
||||
|
||||
/** MCP tool patterns/names to include (supports wildcards like 'category_*') */
|
||||
mcpTools: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform services required for creating platform tools
|
||||
* Can be provided as direct references or getter functions (for lazy initialization)
|
||||
*/
|
||||
export interface PlatformServices {
|
||||
ohlcService?: OHLCService | (() => OHLCService | undefined);
|
||||
symbolIndexService?: SymbolIndexService | (() => SymbolIndexService | undefined);
|
||||
workspaceManager?: WorkspaceManager | (() => WorkspaceManager | undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool Registry
|
||||
*
|
||||
* Manages tool creation and agent-to-tool mappings.
|
||||
* Supports:
|
||||
* - Platform tools (local services like symbol lookup, chart data)
|
||||
* - Remote MCP tools (per-user, session-scoped)
|
||||
* - Configurable tool routing (which tools for which agents)
|
||||
*/
|
||||
export class ToolRegistry {
|
||||
private logger: FastifyBaseLogger;
|
||||
private platformServices: PlatformServices;
|
||||
private agentToolConfigs: Map<string, AgentToolConfig> = new Map();
|
||||
|
||||
constructor(logger: FastifyBaseLogger, platformServices: PlatformServices) {
|
||||
this.logger = logger;
|
||||
this.platformServices = platformServices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register agent tool configuration
|
||||
*/
|
||||
registerAgentTools(config: AgentToolConfig): void {
|
||||
this.agentToolConfigs.set(config.agentName, config);
|
||||
this.logger.debug(
|
||||
{
|
||||
agent: config.agentName,
|
||||
platformTools: config.platformTools,
|
||||
mcpTools: config.mcpTools,
|
||||
},
|
||||
'Registered agent tool configuration'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools for a specific agent
|
||||
*
|
||||
* @param agentName - Name of the agent ('main', 'research', etc.)
|
||||
* @param mcpClient - MCP client for remote tools (optional)
|
||||
* @param availableMCPTools - List of available MCP tools from user's server (optional)
|
||||
* @param workspaceManager - Workspace manager for this session (optional, used by some platform tools)
|
||||
* @returns Array of tools for this agent
|
||||
*/
|
||||
async getToolsForAgent(
|
||||
agentName: string,
|
||||
mcpClient?: MCPClientConnector,
|
||||
availableMCPTools?: MCPToolInfo[],
|
||||
workspaceManager?: WorkspaceManager,
|
||||
onImage?: (image: { data: string; mimeType: string }) => void
|
||||
): Promise<DynamicStructuredTool[]> {
|
||||
const config = this.agentToolConfigs.get(agentName);
|
||||
|
||||
if (!config) {
|
||||
this.logger.warn({ agent: agentName }, 'No tool configuration found for agent');
|
||||
return [];
|
||||
}
|
||||
|
||||
const tools: DynamicStructuredTool[] = [];
|
||||
|
||||
// Add platform tools
|
||||
for (const toolName of config.platformTools) {
|
||||
const tool = await this.getPlatformTool(toolName, workspaceManager);
|
||||
if (tool) {
|
||||
tools.push(tool);
|
||||
} else {
|
||||
this.logger.warn({ agent: agentName, tool: toolName }, 'Platform tool not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Add MCP tools (if MCP client and tools are available)
|
||||
if (mcpClient && availableMCPTools && availableMCPTools.length > 0) {
|
||||
const filteredMCPTools = this.filterMCPTools(availableMCPTools, config.mcpTools);
|
||||
const mcpToolInstances = createMCPToolWrappers(filteredMCPTools, mcpClient, this.logger, onImage);
|
||||
tools.push(...mcpToolInstances);
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
agent: agentName,
|
||||
mcpToolCount: mcpToolInstances.length,
|
||||
mcpToolNames: mcpToolInstances.map(t => t.name),
|
||||
},
|
||||
'Added MCP tools for agent'
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
agent: agentName,
|
||||
toolCount: tools.length,
|
||||
toolNames: tools.map(t => t.name),
|
||||
},
|
||||
'Retrieved tools for agent'
|
||||
);
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a platform tool by name
|
||||
*
|
||||
* @param toolName - Name of the tool to create
|
||||
* @param sessionWorkspaceManager - Optional session-specific workspace manager
|
||||
*/
|
||||
private async getPlatformTool(
|
||||
toolName: string,
|
||||
sessionWorkspaceManager?: WorkspaceManager
|
||||
): Promise<DynamicStructuredTool | null> {
|
||||
// Don't cache tools - recreate each time to get latest services
|
||||
// (services might be initialized asynchronously after registry creation)
|
||||
|
||||
// Create tool based on name
|
||||
let tool: DynamicStructuredTool | null = null;
|
||||
|
||||
switch (toolName) {
|
||||
case 'symbol_lookup': {
|
||||
const symbolIndexService = this.resolveService(this.platformServices.symbolIndexService);
|
||||
if (symbolIndexService) {
|
||||
tool = createSymbolLookupTool({
|
||||
symbolIndexService,
|
||||
logger: this.logger,
|
||||
});
|
||||
} else {
|
||||
this.logger.warn('SymbolIndexService not available for symbol_lookup tool');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'get_chart_data': {
|
||||
const ohlcService = this.resolveService(this.platformServices.ohlcService);
|
||||
// Use session workspace manager if provided, otherwise try global
|
||||
const workspaceManager = sessionWorkspaceManager ||
|
||||
this.resolveService(this.platformServices.workspaceManager);
|
||||
if (ohlcService && workspaceManager) {
|
||||
tool = createGetChartDataTool({
|
||||
ohlcService,
|
||||
workspaceManager,
|
||||
logger: this.logger,
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(
|
||||
{ hasOHLC: !!ohlcService, hasWorkspace: !!workspaceManager },
|
||||
'OHLCService or WorkspaceManager not available for get_chart_data tool'
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn({ tool: toolName }, 'Unknown platform tool');
|
||||
return null;
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a service (handle both direct references and getter functions)
|
||||
*/
|
||||
private resolveService<T>(service: T | (() => T | undefined) | undefined): T | undefined {
|
||||
// Check if it's a function by checking the type more carefully
|
||||
if (service && typeof (service as any) === 'function' && !(service as any).prototype) {
|
||||
// It's a getter function (arrow function or function expression, not a class)
|
||||
return (service as () => T | undefined)();
|
||||
}
|
||||
return service as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter MCP tools based on patterns/names
|
||||
* Supports wildcards like 'category_*' or exact names like 'execute_research'
|
||||
*/
|
||||
private filterMCPTools(availableTools: MCPToolInfo[], patterns: string[]): MCPToolInfo[] {
|
||||
if (patterns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return availableTools.filter(tool => {
|
||||
for (const pattern of patterns) {
|
||||
if (this.matchesPattern(tool.name, pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool name matches a pattern
|
||||
* Supports wildcards: 'category_*' matches 'category_write', 'category_read', etc.
|
||||
*/
|
||||
private matchesPattern(toolName: string, pattern: string): boolean {
|
||||
if (pattern === toolName) {
|
||||
return true; // Exact match
|
||||
}
|
||||
|
||||
if (pattern.includes('*')) {
|
||||
// Convert wildcard pattern to regex
|
||||
const regexPattern = pattern
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.');
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return regex.test(toolName);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered agent names
|
||||
*/
|
||||
getRegisteredAgents(): string[] {
|
||||
return Array.from(this.agentToolConfigs.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool configuration for an agent
|
||||
*/
|
||||
getAgentToolConfig(agentName: string): AgentToolConfig | null {
|
||||
return this.agentToolConfigs.get(agentName) || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global registry instance (initialized at gateway startup)
|
||||
*/
|
||||
let globalToolRegistry: ToolRegistry | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the global tool registry
|
||||
*/
|
||||
export function initializeToolRegistry(
|
||||
logger: FastifyBaseLogger,
|
||||
platformServices: PlatformServices
|
||||
): ToolRegistry {
|
||||
if (globalToolRegistry) {
|
||||
logger.warn('Global tool registry already initialized');
|
||||
return globalToolRegistry;
|
||||
}
|
||||
|
||||
globalToolRegistry = new ToolRegistry(logger, platformServices);
|
||||
|
||||
logger.info('Tool registry initialized');
|
||||
|
||||
return globalToolRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global tool registry
|
||||
*/
|
||||
export function getToolRegistry(): ToolRegistry {
|
||||
if (!globalToolRegistry) {
|
||||
throw new Error('Tool registry not initialized. Call initializeToolRegistry() first.');
|
||||
}
|
||||
|
||||
return globalToolRegistry;
|
||||
}
|
||||
Reference in New Issue
Block a user