major agent refactoring: wiki knowledge base, no RAG, no Qdrant, no Ollama

This commit is contained in:
2026-04-21 21:03:24 -04:00
parent 7e4b54d701
commit 44a1688657
80 changed files with 2699 additions and 4267 deletions

View File

@@ -410,6 +410,24 @@ export class WebSocketHandler {
socket.send(JSON.stringify({ type: 'details_error', category, name, error: 'Failed to read details' }));
}
}
} else if (payload.type === 'read_output') {
// Read persisted output (analysis + images) for a research item
const { category, name } = payload;
if (!harness) {
socket.send(JSON.stringify({ type: 'output_error', category, name, error: 'Session not ready' }));
} else {
try {
const output = await harness.readOutput(category, name);
if (!output) {
socket.send(JSON.stringify({ type: 'output_error', category, name, error: 'No output found — run the script first' }));
} else {
socket.send(jsonStringifySafe({ type: 'output_data', category, name, ...output }));
}
} catch (error) {
logger.error({ error, category, name }, 'Error reading output');
socket.send(JSON.stringify({ type: 'output_error', category, name, error: 'Failed to read output' }));
}
}
} else if (payload.type === 'update_details') {
// User submitted a revised details string — diff and invoke the appropriate subagent
const { category, name, details: newDetails } = payload;
@@ -790,7 +808,7 @@ export class WebSocketHandler {
break;
}
try {
const mcpResult = await harness.callMcpTool('evaluate_indicator', {
const mcpResult = await harness.callMcpTool('EvaluateIndicator', {
symbol: payload.symbol,
from_time: payload.from_time,
to_time: payload.to_time,

View File

@@ -1,326 +0,0 @@
import { QdrantClient as QdrantRestClient } from '@qdrant/js-client-rest';
import type { FastifyBaseLogger } from 'fastify';
/**
* Qdrant client configuration
*/
export interface QdrantConfig {
url: string;
apiKey?: string;
collectionName?: string;
}
/**
* Qdrant client wrapper for RAG vector storage
*
* Features:
* - Global namespace (user_id = "0") for platform knowledge
* - User-specific namespaces for personal memories
* - Payload-indexed by user_id for GDPR compliance
* - Cosine similarity search
*/
export class QdrantClient {
private client: QdrantRestClient;
private collectionName: string;
private vectorDimension: number;
private logger: FastifyBaseLogger;
constructor(config: QdrantConfig, logger: FastifyBaseLogger, vectorDimension: number = 1536) {
this.logger = logger;
this.collectionName = config.collectionName || 'gateway_memory';
this.vectorDimension = vectorDimension;
// Initialize Qdrant REST client
this.client = new QdrantRestClient({
url: config.url,
apiKey: config.apiKey,
});
this.logger.info({
url: config.url,
collection: this.collectionName,
vectorDimension,
}, 'Qdrant client initialized');
}
/**
* Initialize collection with proper schema and indexes
*/
async initialize(): Promise<void> {
this.logger.info({ collection: this.collectionName }, 'Initializing Qdrant collection');
try {
// Check if collection exists
const collections = await this.client.getCollections();
const exists = collections.collections.some(c => c.name === this.collectionName);
if (!exists) {
this.logger.info({ collection: this.collectionName }, 'Creating new collection');
// Create collection with vector configuration
await this.client.createCollection(this.collectionName, {
vectors: {
size: this.vectorDimension,
distance: 'Cosine',
},
});
// Create payload indexes for efficient filtering
await this.client.createPayloadIndex(this.collectionName, {
field_name: 'user_id',
field_schema: 'keyword',
});
await this.client.createPayloadIndex(this.collectionName, {
field_name: 'session_id',
field_schema: 'keyword',
});
await this.client.createPayloadIndex(this.collectionName, {
field_name: 'timestamp',
field_schema: 'integer',
});
this.logger.info({ collection: this.collectionName }, 'Collection created successfully');
} else {
this.logger.info({ collection: this.collectionName }, 'Collection already exists');
}
} catch (error) {
this.logger.error({ error, collection: this.collectionName }, 'Failed to initialize collection');
throw error;
}
}
/**
* Store a vector point with payload
*/
async upsertPoint(
id: string,
vector: number[],
payload: Record<string, any>
): Promise<void> {
try {
await this.client.upsert(this.collectionName, {
wait: true,
points: [{
id,
vector,
payload,
}],
});
} catch (error) {
this.logger.error({ error, id }, 'Failed to upsert point');
throw error;
}
}
/**
* Search for similar vectors
* Queries both global (user_id="0") and user-specific vectors
*/
async search(
userId: string,
queryVector: number[],
options?: {
limit?: number;
scoreThreshold?: number;
sessionId?: string;
timeRange?: { start: number; end: number };
}
): Promise<Array<{
id: string;
score: number;
payload: Record<string, any>;
}>> {
const limit = options?.limit || 5;
const scoreThreshold = options?.scoreThreshold || 0.7;
try {
// Build filter: (user_id = userId OR user_id = "0") AND other conditions
const mustConditions: any[] = [];
const shouldConditions: any[] = [
{ key: 'user_id', match: { value: userId } },
{ key: 'user_id', match: { value: '0' } }, // Global namespace
];
// Add session filter if provided
if (options?.sessionId) {
mustConditions.push({
key: 'session_id',
match: { value: options.sessionId },
});
}
// Add time range filter if provided
if (options?.timeRange) {
mustConditions.push({
key: 'timestamp',
range: {
gte: options.timeRange.start,
lte: options.timeRange.end,
},
});
}
// Perform search
const results = await this.client.search(this.collectionName, {
vector: queryVector,
filter: {
must: mustConditions.length > 0 ? mustConditions : undefined,
should: shouldConditions,
},
limit,
score_threshold: scoreThreshold,
with_payload: true,
});
return results.map(r => ({
id: r.id as string,
score: r.score,
payload: r.payload || {},
}));
} catch (error) {
this.logger.error({ error, userId }, 'Search failed');
throw error;
}
}
/**
* Get points by filter (without vector search)
*/
async scroll(
userId: string,
options?: {
limit?: number;
sessionId?: string;
offset?: string;
}
): Promise<{
points: Array<{ id: string; payload: Record<string, any> }>;
nextOffset?: string;
}> {
try {
const filter: any = {
must: [
{ key: 'user_id', match: { value: userId } },
],
};
if (options?.sessionId) {
filter.must.push({
key: 'session_id',
match: { value: options.sessionId },
});
}
const result = await this.client.scroll(this.collectionName, {
filter,
limit: options?.limit || 10,
offset: options?.offset,
with_payload: true,
with_vector: false,
});
return {
points: result.points.map(p => ({
id: p.id as string,
payload: p.payload || {},
})),
nextOffset: result.next_page_offset as string | undefined,
};
} catch (error) {
this.logger.error({ error, userId }, 'Scroll failed');
throw error;
}
}
/**
* Delete all points for a user (GDPR compliance)
*/
async deleteUserData(userId: string): Promise<void> {
this.logger.info({ userId }, 'Deleting user vectors for GDPR compliance');
try {
await this.client.delete(this.collectionName, {
wait: true,
filter: {
must: [
{ key: 'user_id', match: { value: userId } },
],
},
});
this.logger.info({ userId }, 'User vectors deleted');
} catch (error) {
this.logger.error({ error, userId }, 'Failed to delete user data');
throw error;
}
}
/**
* Delete points for a specific session
*/
async deleteSession(userId: string, sessionId: string): Promise<void> {
this.logger.info({ userId, sessionId }, 'Deleting session vectors');
try {
await this.client.delete(this.collectionName, {
wait: true,
filter: {
must: [
{ key: 'user_id', match: { value: userId } },
{ key: 'session_id', match: { value: sessionId } },
],
},
});
this.logger.info({ userId, sessionId }, 'Session vectors deleted');
} catch (error) {
this.logger.error({ error, userId, sessionId }, 'Failed to delete session');
throw error;
}
}
/**
* Get collection info and statistics
*/
async getCollectionInfo(): Promise<{
vectorsCount: number;
indexedVectorsCount: number;
pointsCount: number;
}> {
try {
const info = await this.client.getCollection(this.collectionName);
return {
vectorsCount: (info as any).vectors_count || 0,
indexedVectorsCount: info.indexed_vectors_count || 0,
pointsCount: info.points_count || 0,
};
} catch (error) {
// If the collection was lost (e.g. Qdrant restarted without the gateway restarting),
// recreate it and return zeroed stats rather than propagating the error.
if ((error as any)?.status === 404) {
this.logger.warn({ collection: this.collectionName }, 'Collection missing, recreating...');
await this.initialize();
return { vectorsCount: 0, indexedVectorsCount: 0, pointsCount: 0 };
}
this.logger.error({ error }, 'Failed to get collection info');
throw error;
}
}
/**
* Store global platform knowledge (user_id = "0")
*/
async storeGlobalKnowledge(
id: string,
vector: number[],
payload: Omit<Record<string, any>, 'user_id'>
): Promise<void> {
return this.upsertPoint(id, vector, {
...payload,
user_id: '0', // Global namespace
});
}
}

View File

@@ -41,8 +41,8 @@ Tiered storage architecture:
Standard LangChain tools following deep agents best practices:
**Platform Tools** (local services):
- `symbol_lookup`: Symbol search and metadata resolution
- `get_chart_data`: OHLCV data with workspace defaults
- `SymbolLookup`: Symbol search and metadata resolution
- `GetChartData`: OHLCV data with workspace defaults
**MCP Tools** (remote, per-user):
- Dynamically discovered from user's MCP server
@@ -89,8 +89,8 @@ subagents/
**Tool Configuration** (in `config.yaml`):
```yaml
tools:
platform: ['symbol_lookup'] # Platform tools
mcp: ['python_*'] # MCP tool patterns
platform: ['SymbolLookup'] # Platform tools
mcp: ['Python*'] # MCP tool patterns
```
**Example:**

View File

@@ -12,20 +12,15 @@ import { ModelRouter, RoutingStrategy } from '../llm/router.js';
import type { ModelMiddleware } from '../llm/middleware.js';
import type { WorkspaceManager } from '../workspace/workspace-manager.js';
import type { ChannelAdapter } from '../workspace/index.js';
import type { ResearchSubagent } from './subagents/research/index.js';
import type { IndicatorSubagent } from './subagents/indicator/index.js';
import type { WebExploreSubagent } from './subagents/web-explore/index.js';
import type { StrategySubagent } from './subagents/strategy/index.js';
import { BaseSubagent } from './subagents/base-subagent.js';
import type { DynamicStructuredTool } from '@langchain/core/tools';
import { getToolRegistry } from '../tools/tool-registry.js';
import type { MCPToolInfo } from '../tools/mcp/mcp-tool-wrapper.js';
import { createResearchAgentTool } from '../tools/platform/research-agent.tool.js';
import { createIndicatorAgentTool } from '../tools/platform/indicator-agent.tool.js';
import { createWebExploreAgentTool } from '../tools/platform/web-explore-agent.tool.js';
import { createStrategyAgentTool } from '../tools/platform/strategy-agent.tool.js';
import { createUserContext } from './memory/session-context.js';
import { createSpawnTool } from '../tools/platform/spawn.tool.js';
import { createMemoryLookupTool } from '../tools/platform/memory-lookup.tool.js';
import { WikiLoader } from './spawn/wiki-loader.js';
import { SpawnService } from './spawn/spawn-service.js';
import type { HarnessEvent } from './harness-events.js';
import { getToolLabel } from './tool-labels.js';
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
@@ -61,10 +56,6 @@ export interface AgentHarnessConfig extends HarnessSessionConfig {
conversationStore?: ConversationStore;
blobStore?: BlobStore;
historyLimit: number;
researchSubagent?: ResearchSubagent;
indicatorSubagent?: IndicatorSubagent;
webExploreSubagent?: WebExploreSubagent;
strategySubagent?: StrategySubagent;
}
/**
@@ -79,7 +70,6 @@ export interface AgentHarnessConfig extends HarnessSessionConfig {
* 5. Saves messages back to user's MCP
*/
export class AgentHarness {
private static systemPromptTemplate: string | null = null;
private static welcomePrompt: string | null = null;
private config: AgentHarnessConfig;
@@ -89,24 +79,17 @@ export class AgentHarness {
private mcpClient: MCPClientConnector;
private workspaceManager?: WorkspaceManager;
private channelAdapter?: ChannelAdapter;
private researchSubagent?: ResearchSubagent;
private availableMCPTools: MCPToolInfo[] = [];
private researchImageCapture: Array<{ data: string; mimeType: string }> = [];
private conversationStore?: ConversationStore;
private indicatorSubagent?: IndicatorSubagent;
private webExploreSubagent?: WebExploreSubagent;
private strategySubagent?: StrategySubagent;
private blobStore?: BlobStore;
private abortController: AbortController | null = null;
private wikiLoader: WikiLoader;
private spawnService: SpawnService;
constructor(config: AgentHarnessConfig) {
this.config = config;
this.workspaceManager = config.workspaceManager;
this.channelAdapter = config.channelAdapter;
this.researchSubagent = config.researchSubagent;
this.indicatorSubagent = config.indicatorSubagent;
this.webExploreSubagent = config.webExploreSubagent;
this.strategySubagent = config.strategySubagent;
this.modelFactory = new LLMProviderFactory(config.providerConfig, config.logger);
this.modelRouter = new ModelRouter(this.modelFactory, config.logger);
@@ -119,17 +102,54 @@ export class AgentHarness {
logger: config.logger,
});
}
this.wikiLoader = new WikiLoader();
this.spawnService = new SpawnService(
this.wikiLoader,
getToolRegistry(),
async (maxTokens?: number) => {
const { model } = await this.modelRouter.route(
'analyze and backtest research data',
this.config.license,
RoutingStrategy.COMPLEXITY,
this.config.userId,
maxTokens,
);
return model;
},
config.logger,
);
/**
* Load system prompt template from file (cached)
*/
private static async loadSystemPromptTemplate(): Promise<string> {
if (!AgentHarness.systemPromptTemplate) {
const templatePath = join(__dirname, 'prompts', 'system-prompt.md');
AgentHarness.systemPromptTemplate = await readFile(templatePath, 'utf-8');
}
return AgentHarness.systemPromptTemplate;
// Register the custom-indicators virtual wiki page
this.wikiLoader.registerVirtual('custom-indicators', async (ctx) => {
if (!ctx.mcpClient) return '';
return this.fetchCustomIndicatorsSection(ctx.mcpClient);
});
// Register conda environment packages as a virtual wiki page
this.wikiLoader.registerVirtual('conda-environment', async (ctx) => {
if (!ctx.mcpClient) return '';
try {
const uri = `dexorder://user/${ctx.mcpClient.userId}/environment.yml`;
const resource = await ctx.mcpClient.readResource(uri);
if (!resource.text) return '';
return `## Available Python Packages (Conda Environment)\n\nThe following packages are pre-installed and available to all scripts:\n\n\`\`\`yaml\n${resource.text}\n\`\`\``;
} catch {
return '';
}
});
// Register the user-preferences virtual wiki page (loaded fresh each turn)
this.wikiLoader.registerVirtual('user-preferences', async (ctx) => {
if (!ctx.mcpClient) return '';
try {
const result = await ctx.mcpClient.callTool('PreferencesRead', {});
const parsed = JSON.parse(String(result));
if (!parsed.exists || !parsed.content?.trim()) return '';
return `## User Preferences\n\n${parsed.content}`;
} catch {
return '';
}
});
}
/**
@@ -169,15 +189,6 @@ export class AgentHarness {
// Discover available MCP tools from user's server
await this.discoverMCPTools();
// Initialize web explore subagent first — research and indicator subagents inject it as a tool
await this.initializeWebExploreSubagent();
// Initialize research subagent if not provided
await this.initializeResearchSubagent();
// Initialize indicator subagent if not provided
await this.initializeIndicatorSubagent();
this.config.logger.info('Agent harness initialized');
} catch (error) {
this.config.logger.error({ error }, 'Failed to initialize agent harness');
@@ -225,292 +236,53 @@ export class AgentHarness {
}
/**
* Initialize research subagent
* Fetch custom indicators from the sandbox and return a formatted markdown section.
* Used as the virtual wiki page 'custom-indicators'.
*/
private async initializeResearchSubagent(): Promise<void> {
if (this.researchSubagent) {
this.config.logger.debug('Research subagent already provided');
return;
}
this.config.logger.debug('Creating research subagent for session');
private async fetchCustomIndicatorsSection(mcpClient: MCPClientConnector): Promise<string> {
try {
const { createResearchSubagent } = await import('./subagents/research/index.js');
const raw = await mcpClient.callTool('PythonList', { category: 'indicator' });
const r = raw as any;
const text = r?.content?.[0]?.text ?? r?.[0]?.text;
const parsed = typeof text === 'string' ? JSON.parse(text) : raw;
const items: any[] = parsed?.items ?? [];
if (items.length === 0) return '';
// Path resolution: use the compiled output path
const researchSubagentPath = join(__dirname, 'subagents', 'research');
this.config.logger.debug({ researchSubagentPath }, 'Using research subagent path');
const lines: string[] = ['\n\n## Custom Indicators\n'];
lines.push('The user has defined the following custom indicators. Use `ta.custom_<name>` where `<name>` is the lowercase sanitized function name shown below.\n');
// Load the subagent config to get maxTokens — research scripts require more tokens
// than the provider default (4096) because python_write arguments include full code bodies
const researchSubagentConfig = await BaseSubagent.loadConfig(researchSubagentPath);
for (const item of items) {
const displayName: string = item.name ?? 'unknown';
const description: string = item.description ?? '';
const meta: any = item.metadata ?? {};
const taAttr = `custom_${displayName.toLowerCase().replace(/[^\w]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '')}`;
const inputSeries: string[] = meta.input_series ?? ['close'];
const params: Record<string, any> = meta.parameters ?? {};
const pane: string = meta.pane ?? 'separate';
// Create a model for the research subagent — always use the complex model
// since research tasks involve data analysis, charting, and code generation
const { model } = await this.modelRouter.route(
'analyze and backtest research data', // triggers complex routing
this.config.license,
RoutingStrategy.COMPLEXITY,
this.config.userId,
researchSubagentConfig.maxTokens // honour the subagent's maxTokens (e.g. 8192)
);
const inputStr = inputSeries.map((s: string) => `df['${s}']`).join(', ');
const paramStr = Object.entries(params)
.map(([k, v]: [string, any]) => `${k}=${JSON.stringify(v?.default ?? null)}`)
.join(', ');
const callExample = paramStr
? `ta.${taAttr}(${inputStr}, ${paramStr})`
: `ta.${taAttr}(${inputStr})`;
// Get tools for research subagent from registry
// Images from MCP responses are captured via onImage and routed to the subagent
const toolRegistry = getToolRegistry();
const researchTools = await toolRegistry.getToolsForAgent(
'research',
this.mcpClient,
this.availableMCPTools,
this.workspaceManager,
(img) => this.researchImageCapture.push(img),
(storeName, newState) => {
this.workspaceManager?.setState(storeName, newState).catch((err) =>
this.config.logger.error({ err, storeName }, 'Failed to sync workspace after research mutation')
);
}
);
const outputNames = (meta.output_columns ?? [{ name: 'value' }])
.map((c: any) => c.name)
.join(', ');
// Inject web_explore tool if the web-explore subagent is ready
if (this.webExploreSubagent) {
const webExploreContext = {
userContext: createUserContext({
userId: this.config.userId,
sessionId: this.config.sessionId,
license: this.config.license,
channelType: this.config.channelType ?? ChannelType.WEBSOCKET,
channelUserId: this.config.channelUserId ?? this.config.userId,
}),
};
researchTools.push(createWebExploreAgentTool({
webExploreSubagent: this.webExploreSubagent,
context: webExploreContext,
logger: this.config.logger,
}));
lines.push(`### ${displayName}`);
if (description) lines.push(description);
lines.push(`- **Call**: \`${callExample}\``);
lines.push(`- **Outputs**: ${outputNames} | **Pane**: ${pane}`);
lines.push('');
}
this.researchSubagent = await createResearchSubagent(
model,
this.config.logger,
researchSubagentPath,
this.mcpClient,
researchTools,
this.researchImageCapture
);
this.config.logger.info(
{
toolCount: researchTools.length,
toolNames: researchTools.map(t => t.name),
},
'Research subagent created successfully'
);
} catch (error) {
this.config.logger.error(
{ error, errorMessage: (error as Error).message, stack: (error as Error).stack },
'Failed to create research subagent'
);
// Don't throw - research subagent is optional
}
}
/**
* Initialize indicator subagent
*/
private async initializeIndicatorSubagent(): Promise<void> {
if (this.indicatorSubagent) {
this.config.logger.debug('Indicator subagent already provided');
return;
}
this.config.logger.debug('Creating indicator subagent for session');
try {
const { createIndicatorSubagent } = await import('./subagents/indicator/index.js');
const { model } = await this.modelRouter.route(
'indicator management',
this.config.license,
RoutingStrategy.COMPLEXITY,
this.config.userId
);
const toolRegistry = getToolRegistry();
const indicatorTools = await toolRegistry.getToolsForAgent(
'indicator',
this.mcpClient,
this.availableMCPTools,
this.workspaceManager,
undefined, // no image callback
(storeName, newState) => {
// After a workspace_patch succeeds in the container, update the gateway's
// WorkspaceManager so it pushes a WebSocket patch to the web client.
this.workspaceManager?.setState(storeName, newState).catch((err) =>
this.config.logger.error({ err, storeName }, 'Failed to sync workspace after indicator mutation')
);
}
);
// Inject web_explore tool if the web-explore subagent is ready
if (this.webExploreSubagent) {
const webExploreContext = {
userContext: createUserContext({
userId: this.config.userId,
sessionId: this.config.sessionId,
license: this.config.license,
channelType: this.config.channelType ?? ChannelType.WEBSOCKET,
channelUserId: this.config.channelUserId ?? this.config.userId,
}),
};
indicatorTools.push(createWebExploreAgentTool({
webExploreSubagent: this.webExploreSubagent,
context: webExploreContext,
logger: this.config.logger,
}));
}
const indicatorSubagentPath = join(__dirname, 'subagents', 'indicator');
this.config.logger.debug({ indicatorSubagentPath }, 'Using indicator subagent path');
this.indicatorSubagent = await createIndicatorSubagent(
model,
this.config.logger,
indicatorSubagentPath,
this.mcpClient,
indicatorTools
);
this.config.logger.info(
{
toolCount: indicatorTools.length,
toolNames: indicatorTools.map(t => t.name),
},
'Indicator subagent created successfully'
);
} catch (error) {
this.config.logger.error(
{ error, errorMessage: (error as Error).message, stack: (error as Error).stack },
'Failed to create indicator subagent'
);
// Don't throw — indicator subagent is optional
}
}
/**
* Initialize web explore subagent
*/
private async initializeWebExploreSubagent(): Promise<void> {
if (this.webExploreSubagent) {
this.config.logger.debug('Web explore subagent already provided');
return;
}
this.config.logger.debug('Creating web explore subagent for session');
try {
const { createWebExploreSubagent } = await import('./subagents/web-explore/index.js');
const { model } = await this.modelRouter.route(
'web research and summarization',
this.config.license,
RoutingStrategy.COMPLEXITY,
this.config.userId
);
const toolRegistry = getToolRegistry();
const webExploreTools = await toolRegistry.getToolsForAgent(
'web-explore',
undefined, // no MCP client needed
undefined,
undefined
);
const webExploreSubagentPath = join(__dirname, 'subagents', 'web-explore');
this.config.logger.debug({ webExploreSubagentPath }, 'Using web explore subagent path');
this.webExploreSubagent = await createWebExploreSubagent(
model,
this.config.logger,
webExploreSubagentPath,
webExploreTools
);
this.config.logger.info(
{
toolCount: webExploreTools.length,
toolNames: webExploreTools.map(t => t.name),
},
'Web explore subagent created successfully'
);
} catch (error) {
this.config.logger.error(
{ error, errorMessage: (error as Error).message, stack: (error as Error).stack },
'Failed to create web explore subagent'
);
// Don't throw — web explore subagent is optional
}
}
/**
* Initialize strategy subagent
*/
private async initializeStrategySubagent(): Promise<void> {
if (this.strategySubagent) {
this.config.logger.debug('Strategy subagent already provided');
return;
}
this.config.logger.debug('Creating strategy subagent for session');
try {
const { createStrategySubagent } = await import('./subagents/strategy/index.js');
const { model } = await this.modelRouter.route(
'trading strategy writing and backtesting',
this.config.license,
RoutingStrategy.COMPLEXITY,
this.config.userId
);
const toolRegistry = getToolRegistry();
const strategyTools = await toolRegistry.getToolsForAgent(
'strategy',
this.mcpClient,
this.availableMCPTools,
this.workspaceManager,
undefined,
(storeName, newState) => {
this.workspaceManager?.setState(storeName, newState).catch((err) =>
this.config.logger.error({ err, storeName }, 'Failed to sync workspace after strategy mutation')
);
}
);
const strategySubagentPath = join(__dirname, 'subagents', 'strategy');
this.config.logger.debug({ strategySubagentPath }, 'Using strategy subagent path');
this.strategySubagent = await createStrategySubagent(
model,
this.config.logger,
strategySubagentPath,
this.mcpClient,
strategyTools
);
this.config.logger.info(
{
toolCount: strategyTools.length,
toolNames: strategyTools.map(t => t.name),
},
'Strategy subagent created successfully'
);
} catch (error) {
this.config.logger.error(
{ error, errorMessage: (error as Error).message, stack: (error as Error).stack },
'Failed to create strategy subagent'
);
// Don't throw — strategy subagent is optional
return lines.join('\n');
} catch (err) {
this.config.logger.warn({ err }, 'Failed to fetch custom indicators for wiki page');
return '';
}
}
@@ -640,7 +412,7 @@ export class AgentHarness {
}
try {
yield { type: 'tool_call', toolName: toolCall.name, label: this.getToolLabel(toolCall.name) };
yield { type: 'tool_call', toolName: toolCall.name, label: getToolLabel(toolCall.name, toolCall.args) };
// Use streamFunc when available (subagent tools) to forward intermediate events inline
let result: string;
@@ -727,7 +499,7 @@ export class AgentHarness {
/**
* Call a tool on the user's MCP server directly (bypasses the agent/LLM).
* Used by channel handlers for direct data requests (e.g. evaluate_indicator).
* Used by channel handlers for direct data requests (e.g. EvaluateIndicator).
*/
async callMcpTool(name: string, args: Record<string, unknown>): Promise<unknown> {
return this.mcpClient.callTool(name, args);
@@ -739,15 +511,15 @@ export class AgentHarness {
*/
async readDetails(category: string, name: string): Promise<string | null> {
try {
const raw = await this.mcpClient.callTool('python_read', { category, name });
const raw = await this.mcpClient.callTool('PythonRead', { category, name });
const content = (raw as any)?.content;
if (!Array.isArray(content)) return null;
for (const item of content) {
if (item.type === 'text' && item.text) {
try {
const parsed = JSON.parse(item.text);
if (parsed?.exists && parsed?.metadata?.details !== undefined) {
return parsed.metadata.details as string;
if (parsed?.exists && parsed?.details !== undefined) {
return parsed.details as string;
}
} catch { /* ignore */ }
}
@@ -758,6 +530,32 @@ export class AgentHarness {
}
}
/**
* Read persisted output files (analysis + images) for a research item.
* Returns null if the item has no output yet.
*/
async readOutput(
category: string,
name: string,
): Promise<{ analysis?: string; images?: Array<{ mimeType: string; data: string }> } | null> {
try {
const raw = await this.mcpClient.callTool('PythonReadOutput', { category, name });
const content = (raw as any)?.content;
if (!Array.isArray(content)) return null;
const result: { analysis?: string; images?: Array<{ mimeType: string; data: string }> } = {};
for (const item of content) {
if (item.type === 'text' && item.text && !item.text.startsWith('output_dir:')) {
result.analysis = item.text;
} else if (item.type === 'image' && item.data) {
(result.images ??= []).push({ mimeType: item.mimeType ?? 'image/png', data: item.data });
}
}
return result;
} catch {
return null;
}
}
/**
* Stream a details-driven code update for a category item.
*
@@ -789,47 +587,37 @@ export class AgentHarness {
// 3. Build instruction for the subagent
const instruction = buildDetailsUpdateInstruction(category, name, newDetails, diff);
// 4. Build a minimal subagent context
const context = {
userContext: createUserContext({
userId: this.config.userId,
sessionId: this.config.sessionId,
license: this.config.license,
channelType: this.config.channelType ?? ChannelType.WEBSOCKET,
channelUserId: this.config.channelUserId ?? this.config.userId,
}),
};
// 4. Determine the agent name for this category
const agentName = category === 'indicator' ? 'indicator'
: category === 'strategy' ? 'strategy'
: category === 'research' ? 'research'
: null;
// 5. Ensure the right subagent is ready and invoke it
if (category === 'indicator') {
if (!this.indicatorSubagent) await this.initializeIndicatorSubagent();
if (!this.indicatorSubagent) {
yield { type: 'error', source: 'indicator', fatal: false };
return;
}
logger.info({ category, name }, 'Streaming indicator details update');
yield* this.indicatorSubagent.streamEvents(context, instruction, signal);
} else if (category === 'strategy') {
if (!this.strategySubagent) await this.initializeStrategySubagent();
if (!this.strategySubagent) {
yield { type: 'error', source: 'strategy', fatal: false };
return;
}
logger.info({ category, name }, 'Streaming strategy details update');
yield* this.strategySubagent.streamEvents(context, instruction, signal);
} else if (category === 'research') {
if (!this.researchSubagent) await this.initializeResearchSubagent();
if (!this.researchSubagent) {
yield { type: 'error', source: 'research', fatal: false };
return;
}
logger.info({ category, name }, 'Streaming research details update');
yield* this.researchSubagent.streamEvents(context, instruction, signal);
} else {
if (!agentName) {
yield { type: 'error', source: 'harness', fatal: false };
return;
}
// 5. Delegate to SpawnService
logger.info({ category, name, agentName }, 'Streaming details update via spawn');
const gen = this.spawnService.streamSpawn({
agentName,
instruction,
mcpClient: this.mcpClient,
availableMCPTools: this.availableMCPTools,
workspaceManager: this.workspaceManager,
signal,
});
let step = await gen.next();
while (!step.done) {
yield step.value;
step = await gen.next();
}
// Final text is the return value of the generator — emit as done event
const finalText = step.value;
if (finalText) {
yield { type: 'done', content: finalText };
}
}
@@ -876,11 +664,21 @@ export class AgentHarness {
: [];
this.config.logger.debug({ historyLength: history.length }, 'Conversation history loaded');
// Inject current workspace state fresh on every turn — not persisted to conversation history
// Inject license info and workspace state as a fresh HumanMessage on every turn
const licenseInfo = `[License]\nlicenseType: ${this.config.license.licenseType}\nfeatures: ${JSON.stringify(this.config.license.features)}`;
const workspaceContext = this.workspaceManager
? `[Workspace State]\n\`\`\`json\n${this.workspaceManager.serializeState()}\n\`\`\``
: undefined;
// Load dynamic imports from agent-main.md frontmatter (fresh per turn, after cache marker)
const agentMainPage = await this.wikiLoader.loadAgentPage('main').catch(() => null);
const dynamicContext = agentMainPage?.frontmatter.dynamic_imports?.length
? await this.wikiLoader.loadDynamicImports(
agentMainPage.frontmatter.dynamic_imports,
{ mcpClient: this.mcpClient, workspaceManager: this.workspaceManager ?? undefined }
)
: '';
// 4. Get the configured model
this.config.logger.debug('Routing to model');
const { model, middleware } = await this.modelRouter.route(
@@ -893,7 +691,7 @@ export class AgentHarness {
this.config.logger.info({ modelName: model.constructor.name }, 'Model selected');
// 5. Build LangChain messages
const langchainMessages = this.buildLangChainMessages(systemPrompt, history, workspaceContext, message.content);
const langchainMessages = this.buildLangChainMessages(systemPrompt, history, licenseInfo, workspaceContext, dynamicContext, message.content);
this.config.logger.debug({ messageCount: langchainMessages.length }, 'LangChain messages built');
// 6. Get tools for main agent from registry
@@ -911,51 +709,19 @@ export class AgentHarness {
}
);
// Build shared subagent context
const subagentContext = {
userContext: createUserContext({
userId: this.config.userId,
sessionId: this.config.sessionId,
license: this.config.license,
channelType: this.config.channelType ?? ChannelType.WEBSOCKET,
channelUserId: this.config.channelUserId ?? this.config.userId,
}),
};
// Add spawn and memory_lookup tools
tools.push(createSpawnTool({
spawnService: this.spawnService,
mcpClient: this.mcpClient,
availableMCPTools: this.availableMCPTools,
workspaceManager: this.workspaceManager,
logger: this.config.logger,
}));
if (this.researchSubagent) {
tools.push(createResearchAgentTool({
researchSubagent: this.researchSubagent,
context: subagentContext,
logger: this.config.logger,
}));
}
if (this.indicatorSubagent) {
tools.push(createIndicatorAgentTool({
indicatorSubagent: this.indicatorSubagent,
context: subagentContext,
logger: this.config.logger,
}));
}
if (this.webExploreSubagent) {
tools.push(createWebExploreAgentTool({
webExploreSubagent: this.webExploreSubagent,
context: subagentContext,
logger: this.config.logger,
}));
}
if (!this.strategySubagent) {
await this.initializeStrategySubagent();
}
if (this.strategySubagent) {
tools.push(createStrategyAgentTool({
strategySubagent: this.strategySubagent,
context: subagentContext,
logger: this.config.logger,
}));
}
tools.push(createMemoryLookupTool({
wikiLoader: this.wikiLoader,
logger: this.config.logger,
}));
this.config.logger.info(
{ toolCount: tools.length, toolNames: tools.map(t => t.name) },
@@ -1088,50 +854,34 @@ export class AgentHarness {
private buildLangChainMessages(
systemPrompt: string,
history: BaseMessage[],
licenseInfo: string,
workspaceContext: string | undefined,
dynamicContext: string,
currentUserMessage: string
): BaseMessage[] {
// License, workspace, and dynamic context are injected as HumanMessages so they are never cached
const contextParts = [licenseInfo, workspaceContext, dynamicContext || undefined].filter(Boolean).join('\n\n');
return [
new SystemMessage(systemPrompt),
...history,
...(workspaceContext ? [new HumanMessage(workspaceContext)] : []),
...(contextParts ? [new HumanMessage(contextParts)] : []),
new HumanMessage(currentUserMessage),
];
}
/**
* Build system prompt from template
* Build system prompt from wiki knowledge base.
* Loads index.md + tools.md (tier-1 cache) and agent-main.md (tier-2 cache).
*/
private async buildSystemPrompt(): Promise<string> {
// Load template and populate with license info
const template = await AgentHarness.loadSystemPromptTemplate();
const prompt = template
.replace('{{licenseType}}', this.config.license.licenseType)
.replace('{{features}}', JSON.stringify(this.config.license.features, null, 2));
const [basePrompt, agentMain] = await Promise.all([
this.wikiLoader.getBasePrompt(),
this.wikiLoader.loadAgentPage('main').catch(() => null),
]);
return prompt;
}
/**
* Map tool names to user-friendly status labels.
*/
private getToolLabel(toolName: string): string {
const labels: Record<string, string> = {
research: 'Researching...',
indicator: 'Adjusting indicators...',
get_chart_data: 'Fetching chart data...',
symbol_lookup: 'Searching symbol...',
python_list: 'Seeing what we have...',
python_edit: 'Coding...',
python_write: 'Coding...',
python_read: 'Inspecting...',
execute_research: 'Running script...',
backtest_strategy: 'Backtesting...',
list_active_strategies: 'Checking active strategies...',
web_explore: 'Searching the web...',
strategy: 'Coding a strategy...',
};
return labels[toolName] ?? `Running ${toolName} tool...`;
const parts = [basePrompt];
if (agentMain) parts.push(agentMain.body);
return parts.join('\n\n---\n\n');
}
/**
@@ -1318,7 +1068,7 @@ function buildDetailsUpdateInstruction(
return `The user has edited the specification (details) for the ${categoryLabel} named "${name}".
Your task: update the Python implementation to match the revised specification. Use \`python_edit\` with targeted patches — make only the changes implied by the diff below. Also update the \`details\` field via the \`details\` parameter on \`python_edit\` to store the new specification text.
Your task: update the Python implementation to match the revised specification. Use \`PythonEdit\` with targeted patches — make only the changes implied by the diff below. Also update the \`details\` field via the \`details\` parameter on \`PythonEdit\` to store the new specification text.
## Revised specification
@@ -1331,7 +1081,7 @@ ${diff}
\`\`\`
Instructions:
- Read the current implementation first with \`python_read(category="${category}", name="${name}")\` to understand what exists.
- Read the current implementation first with \`PythonRead(category="${category}", name="${name}")\` to understand what exists.
- Apply only the changes described by the diff above — do not rewrite unrelated parts of the code.
- Pass \`details\` as the full revised specification text shown above.
- After editing, confirm the change was applied and validation passed.`;

View File

@@ -3,8 +3,8 @@
// Memory
export * from './memory/index.js';
// Subagents
export * from './subagents/index.js';
// Spawn infrastructure
export * from './spawn/index.js';
// Workflows
export * from './workflows/index.js';

View File

@@ -151,7 +151,7 @@ export class MCPClientConnector {
try {
this.config.logger.debug({ tool: name, args }, 'Calling MCP tool');
// Use a generous timeout: execute_research runs a subprocess with a 300s limit,
// Use a generous timeout: ExecuteResearch runs a subprocess with a 300s limit,
// so the default 60s MCP SDK timeout would fire before the script completes.
const result = await this.client!.callTool({ name, arguments: args }, undefined, { timeout: 330000 });
return result;
@@ -293,4 +293,8 @@ export class MCPClientConnector {
isConnected(): boolean {
return this.connected;
}
get userId(): string {
return this.config.userId;
}
}

View File

@@ -1,356 +0,0 @@
import { readdir, readFile } from 'fs/promises';
import { join, relative } from 'path';
import { createHash } from 'crypto';
import type { FastifyBaseLogger } from 'fastify';
import { RAGRetriever } from './rag-retriever.js';
import { EmbeddingService } from './embedding-service.js';
/**
* Document metadata stored with each chunk
*/
export interface DocumentMetadata {
document_id: string;
chunk_index: number;
content_hash: string;
last_updated: number;
tags: string[];
heading?: string;
file_path: string;
}
/**
* Document chunk with content and metadata
*/
export interface DocumentChunk {
content: string;
metadata: DocumentMetadata;
}
/**
* Document loader configuration
*/
export interface DocumentLoaderConfig {
knowledgeDir: string;
maxChunkSize?: number; // in tokens (approximate by chars)
chunkOverlap?: number; // overlap between chunks
}
/**
* Global knowledge document loader
*
* Loads markdown documents from a directory structure and stores them
* as global knowledge (user_id="0") in Qdrant for RAG retrieval.
*
* Features:
* - Intelligent chunking by markdown headers
* - Content hashing for change detection
* - Metadata extraction (tags, headings)
* - Automatic embedding generation
* - Incremental updates (only changed docs)
*
* Directory structure:
* gateway/knowledge/
* platform/
* trading/
* indicators/
* strategies/
*/
export class DocumentLoader {
private config: DocumentLoaderConfig;
private logger: FastifyBaseLogger;
private embeddings: EmbeddingService;
private rag: RAGRetriever;
private loadedDocs: Map<string, string> = new Map(); // path -> hash
constructor(
config: DocumentLoaderConfig,
embeddings: EmbeddingService,
rag: RAGRetriever,
logger: FastifyBaseLogger
) {
this.config = {
maxChunkSize: 4000, // ~1000 tokens
chunkOverlap: 200,
...config,
};
this.embeddings = embeddings;
this.rag = rag;
this.logger = logger;
}
/**
* Load all documents from knowledge directory
*/
async loadAll(): Promise<{ loaded: number; updated: number; skipped: number }> {
this.logger.info({ dir: this.config.knowledgeDir }, 'Loading knowledge documents');
const stats = { loaded: 0, updated: 0, skipped: 0 };
try {
const files = await this.findMarkdownFiles(this.config.knowledgeDir);
for (const filePath of files) {
const result = await this.loadDocument(filePath);
if (result === 'loaded') stats.loaded++;
else if (result === 'updated') stats.updated++;
else stats.skipped++;
}
this.logger.info(stats, 'Knowledge documents loaded');
return stats;
} catch (error) {
this.logger.error({ error }, 'Failed to load knowledge documents');
throw error;
}
}
/**
* Load a single document
*/
async loadDocument(filePath: string): Promise<'loaded' | 'updated' | 'skipped'> {
try {
// Read file content
const content = await readFile(filePath, 'utf-8');
const contentHash = this.hashContent(content);
// Check if document has changed
const relativePath = relative(this.config.knowledgeDir, filePath);
const existingHash = this.loadedDocs.get(relativePath);
if (existingHash === contentHash) {
this.logger.debug({ file: relativePath }, 'Document unchanged, skipping');
return 'skipped';
}
const isUpdate = !!existingHash;
// Parse and chunk document
const chunks = this.chunkDocument(content, relativePath);
this.logger.info(
{ file: relativePath, chunks: chunks.length, update: isUpdate },
'Processing document'
);
// Generate embeddings and store chunks
for (const chunk of chunks) {
const embedding = await this.embeddings.embed(chunk.content);
// Create unique ID for this chunk
const chunkId = `global:${chunk.metadata.document_id}:${chunk.metadata.chunk_index}`;
// Store in Qdrant as global knowledge
await this.rag.storeGlobalKnowledge(
chunkId,
chunk.content,
embedding,
{
...chunk.metadata,
type: 'knowledge_doc',
}
);
}
// Update loaded docs tracking
this.loadedDocs.set(relativePath, contentHash);
return isUpdate ? 'updated' : 'loaded';
} catch (error) {
this.logger.error({ error, file: filePath }, 'Failed to load document');
throw error;
}
}
/**
* Reload a specific document (for updates)
*/
async reloadDocument(filePath: string): Promise<void> {
this.logger.info({ file: filePath }, 'Reloading document');
await this.loadDocument(filePath);
}
/**
* Chunk document by markdown headers with smart splitting
*/
private chunkDocument(content: string, documentId: string): DocumentChunk[] {
const chunks: DocumentChunk[] = [];
const tags = this.extractTags(content);
const lastModified = Date.now();
// Split by headers (## or ###)
const sections = this.splitByHeaders(content);
let chunkIndex = 0;
for (const section of sections) {
// If section is too large, split it further
const subChunks = this.splitLargeSection(section.content);
for (const subContent of subChunks) {
if (subContent.trim().length === 0) continue;
chunks.push({
content: subContent,
metadata: {
document_id: documentId,
chunk_index: chunkIndex++,
content_hash: this.hashContent(content),
last_updated: lastModified,
tags,
heading: section.heading,
file_path: documentId,
},
});
}
}
return chunks;
}
/**
* Split document by markdown headers
*/
private splitByHeaders(content: string): Array<{ heading?: string; content: string }> {
const lines = content.split('\n');
const sections: Array<{ heading?: string; content: string }> = [];
let currentSection: string[] = [];
let currentHeading: string | undefined;
for (const line of lines) {
// Check for markdown header (##, ###, ####)
const headerMatch = line.match(/^(#{2,4})\s+(.+)$/);
if (headerMatch) {
// Save previous section
if (currentSection.length > 0) {
sections.push({
heading: currentHeading,
content: currentSection.join('\n'),
});
}
// Start new section
currentHeading = headerMatch[2].trim();
currentSection = [line];
} else {
currentSection.push(line);
}
}
// Add final section
if (currentSection.length > 0) {
sections.push({
heading: currentHeading,
content: currentSection.join('\n'),
});
}
return sections;
}
/**
* Split large sections into smaller chunks
*/
private splitLargeSection(content: string): string[] {
const maxSize = this.config.maxChunkSize!;
const overlap = this.config.chunkOverlap!;
if (content.length <= maxSize) {
return [content];
}
const chunks: string[] = [];
let start = 0;
while (start < content.length) {
const end = Math.min(start + maxSize, content.length);
let chunkEnd = end;
// Try to break at sentence boundary
if (end < content.length) {
const sentenceEnd = content.lastIndexOf('.', end);
const paragraphEnd = content.lastIndexOf('\n\n', end);
if (paragraphEnd > start + maxSize / 2) {
chunkEnd = paragraphEnd;
} else if (sentenceEnd > start + maxSize / 2) {
chunkEnd = sentenceEnd + 1;
}
}
chunks.push(content.substring(start, chunkEnd));
start = chunkEnd - overlap;
}
return chunks;
}
/**
* Extract tags from document (frontmatter or first heading)
*/
private extractTags(content: string): string[] {
const tags: string[] = [];
// Try to extract from YAML frontmatter
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const frontmatter = frontmatterMatch[1];
const tagsMatch = frontmatter.match(/tags:\s*\[([^\]]+)\]/);
if (tagsMatch) {
tags.push(...tagsMatch[1].split(',').map((t) => t.trim()));
}
}
// Extract from first heading
const headingMatch = content.match(/^#\s+(.+)$/m);
if (headingMatch) {
tags.push(headingMatch[1].toLowerCase().replace(/\s+/g, '-'));
}
return tags;
}
/**
* Hash content for change detection
*/
private hashContent(content: string): string {
return createHash('md5').update(content).digest('hex');
}
/**
* Recursively find all markdown files
*/
private async findMarkdownFiles(dir: string): Promise<string[]> {
const files: string[] = [];
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
const subFiles = await this.findMarkdownFiles(fullPath);
files.push(...subFiles);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(fullPath);
}
}
} catch (error) {
this.logger.warn({ error, dir }, 'Failed to read directory');
}
return files;
}
/**
* Get loaded document stats
*/
getStats(): { totalDocs: number; totalSize: number } {
return {
totalDocs: this.loadedDocs.size,
totalSize: Array.from(this.loadedDocs.values()).reduce((sum, hash) => sum + hash.length, 0),
};
}
}

View File

@@ -1,270 +0,0 @@
import type { FastifyBaseLogger } from 'fastify';
import { Ollama } from 'ollama';
/**
* Embedding provider configuration
*/
export interface EmbeddingConfig {
provider: 'ollama' | 'openai' | 'anthropic' | 'local' | 'voyage' | 'cohere' | 'none';
model?: string;
apiKey?: string;
dimensions?: number;
ollamaUrl?: string;
}
/**
* Embedding service for generating vectors from text
*
* Supports multiple providers:
* - Ollama (all-minilm, nomic-embed-text, mxbai-embed-large) - RECOMMENDED
* - OpenAI (text-embedding-3-small/large)
* - Voyage AI (voyage-2)
* - Cohere (embed-english-v3.0)
* - Local models (via transformers.js or Python sidecar)
* - None (for development without embeddings)
*
* Used by RAGRetriever to generate embeddings for storage and search.
*
* For production, use Ollama with all-minilm (90MB model, runs on CPU, ~100MB RAM).
* Ollama can run in-container or as a separate pod/sidecar.
*/
export class EmbeddingService {
private readonly model: string;
private readonly dimensions: number;
private ollama?: Ollama;
constructor(
private config: EmbeddingConfig,
private logger: FastifyBaseLogger
) {
// Set defaults based on provider
switch (config.provider) {
case 'ollama':
this.model = config.model || 'all-minilm';
this.dimensions = config.dimensions || 384;
this.ollama = new Ollama({
host: config.ollamaUrl || 'http://localhost:11434',
});
break;
case 'openai':
this.model = config.model || 'text-embedding-3-small';
this.dimensions = config.dimensions || 1536;
break;
case 'anthropic':
case 'voyage':
this.model = config.model || 'voyage-2';
this.dimensions = config.dimensions || 1024;
break;
case 'cohere':
this.model = config.model || 'embed-english-v3.0';
this.dimensions = config.dimensions || 1024;
break;
case 'local':
this.model = config.model || 'all-MiniLM-L6-v2';
this.dimensions = config.dimensions || 384;
break;
case 'none':
// No embeddings configured - will return zero vectors
this.model = 'none';
this.dimensions = config.dimensions || 1536;
this.logger.warn('Embedding service initialized with provider=none - RAG will not function properly');
break;
default:
throw new Error(`Unknown embedding provider: ${config.provider}`);
}
if (config.provider !== 'none') {
this.logger.info(
{ provider: config.provider, model: this.model, dimensions: this.dimensions },
'Initialized embedding service'
);
}
}
/**
* Generate embedding for a single text
*/
async embed(text: string): Promise<number[]> {
if (this.config.provider === 'none') {
// Return zero vector when no embeddings configured
return new Array(this.dimensions).fill(0);
}
this.logger.debug({ textLength: text.length, provider: this.config.provider }, 'Generating embedding');
try {
switch (this.config.provider) {
case 'ollama':
return await this.embedOllama(text);
case 'openai':
return await this.embedOpenAI(text);
case 'anthropic':
case 'voyage':
return await this.embedVoyage(text);
case 'cohere':
return await this.embedCohere(text);
case 'local':
return await this.embedLocal(text);
default:
throw new Error(`Unknown provider: ${this.config.provider}`);
}
} catch (error) {
this.logger.error({ error, provider: this.config.provider }, 'Failed to generate embedding');
// Return zero vector as fallback to prevent crashes
return new Array(this.dimensions).fill(0);
}
}
/**
* Generate embeddings for multiple texts (batch)
*/
async embedBatch(texts: string[]): Promise<number[][]> {
this.logger.debug({ count: texts.length, provider: this.config.provider }, 'Generating batch embeddings');
// Ollama supports native batch operations
if (this.config.provider === 'ollama' && this.ollama) {
try {
const response = await this.ollama.embed({
model: this.model,
input: texts,
});
return response.embeddings;
} catch (error) {
this.logger.error({ error }, 'Ollama batch embedding failed, falling back to sequential');
// Fall through to sequential processing
}
}
// Fallback: call embed() for each text sequentially
const embeddings = await Promise.all(texts.map((text) => this.embed(text)));
return embeddings;
}
/**
* Get embedding dimensions
*/
getDimensions(): number {
return this.dimensions;
}
/**
* Get model name
*/
getModel(): string {
return this.model;
}
/**
* Generate embedding using Ollama
*/
private async embedOllama(text: string): Promise<number[]> {
if (!this.ollama) {
this.logger.error('Ollama client not initialized');
return new Array(this.dimensions).fill(0);
}
try {
const response = await this.ollama.embed({
model: this.model,
input: text,
});
// Ollama returns single embedding for single input
return response.embeddings[0];
} catch (error) {
this.logger.error({ error }, 'Ollama embedding failed, returning zero vector');
return new Array(this.dimensions).fill(0);
}
}
/**
* Generate embedding using OpenAI API
*/
private async embedOpenAI(text: string): Promise<number[]> {
if (!this.config.apiKey) {
this.logger.warn('OpenAI API key not configured, returning zero vector');
return new Array(this.dimensions).fill(0);
}
try {
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({
model: this.model,
input: text,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenAI API error: ${response.status} ${errorText}`);
}
const data = await response.json() as { data: Array<{ embedding: number[] }> };
return data.data[0].embedding;
} catch (error) {
this.logger.error({ error }, 'OpenAI embedding failed, returning zero vector');
return new Array(this.dimensions).fill(0);
}
}
/**
* Generate embedding using Voyage AI API (Anthropic partnership)
*/
private async embedVoyage(_text: string): Promise<number[]> {
// TODO: Implement Voyage AI embedding when API key available
// API endpoint: https://api.voyageai.com/v1/embeddings
this.logger.warn('Voyage AI embedding not yet implemented, returning zero vector');
return new Array(this.dimensions).fill(0);
}
/**
* Generate embedding using Cohere API
*/
private async embedCohere(_text: string): Promise<number[]> {
// TODO: Implement Cohere embedding when API key available
// API endpoint: https://api.cohere.ai/v1/embed
this.logger.warn('Cohere embedding not yet implemented, returning zero vector');
return new Array(this.dimensions).fill(0);
}
/**
* Generate embedding using local model
*/
private async embedLocal(_text: string): Promise<number[]> {
// TODO: Implement local embedding (via transformers.js or Python sidecar)
// Options:
// 1. transformers.js (pure JS/WebAssembly) - slower but self-contained
// 2. Python sidecar service running sentence-transformers - faster
// 3. ONNX runtime with pre-exported models - good balance
this.logger.warn('Local embedding not implemented, returning zero vector');
return new Array(this.dimensions).fill(0);
}
/**
* Calculate cosine similarity between two embeddings
*/
static cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error('Embeddings must have same dimensions');
}
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
}

View File

@@ -2,9 +2,6 @@
export { TieredCheckpointSaver } from './checkpoint-saver.js';
export { ConversationStore } from './conversation-store.js';
export { EmbeddingService } from './embedding-service.js';
export { RAGRetriever } from './rag-retriever.js';
export { DocumentLoader } from './document-loader.js';
export {
createUserContext,
touchContext,
@@ -16,5 +13,4 @@ export {
type ActiveChannel,
type ChannelCapabilities,
type WorkspaceContext,
type MemoryChunk,
} from './session-context.js';

View File

@@ -1,210 +0,0 @@
import type { FastifyBaseLogger } from 'fastify';
import { QdrantClient } from '../../clients/qdrant-client.js';
/**
* Vector point with metadata for Qdrant
*/
export interface VectorPoint {
id: string;
vector: number[];
payload: {
user_id: string;
session_id: string;
content: string;
role: 'user' | 'assistant' | 'system';
timestamp: number;
[key: string]: unknown;
};
}
/**
* Search result from Qdrant
*/
export interface SearchResult {
id: string;
score: number;
payload: VectorPoint['payload'];
}
/**
* Qdrant client configuration
*/
export interface QdrantConfig {
url: string;
apiKey?: string;
collectionName?: string;
}
/**
* RAG retriever using Qdrant for vector similarity search
*
* Features:
* - **Global namespace** (user_id="0") for platform knowledge
* - **User-specific namespaces** for personal memories
* - **Queries join both** global and user memories
* - Semantic search across conversation history
* - Context retrieval for agent prompts
* - User preference and pattern learning
*
* Architecture: Gateway-side vector store, user_id indexed for GDPR compliance
*/
export class RAGRetriever {
private qdrant: QdrantClient;
constructor(
config: QdrantConfig,
private logger: FastifyBaseLogger,
vectorDimension: number = 1536
) {
this.qdrant = new QdrantClient(config, logger, vectorDimension);
}
/**
* Initialize Qdrant collection with proper schema
*/
async initialize(): Promise<void> {
await this.qdrant.initialize();
}
/**
* Store conversation message as vector
*/
async storeMessage(
userId: string,
sessionId: string,
role: 'user' | 'assistant' | 'system',
content: string,
embedding: number[],
metadata?: Record<string, unknown>
): Promise<void> {
const id = `${userId}:${sessionId}:${Date.now()}`;
const payload = {
user_id: userId,
session_id: sessionId,
content,
role,
timestamp: Date.now(),
...metadata,
};
this.logger.debug(
{ userId, sessionId, role, contentLength: content.length },
'Storing message vector'
);
await this.qdrant.upsertPoint(id, embedding, payload);
}
/**
* Store global platform knowledge (user_id = "0")
*/
async storeGlobalKnowledge(
id: string,
content: string,
embedding: number[],
metadata?: Record<string, unknown>
): Promise<void> {
this.logger.debug({ id, contentLength: content.length }, 'Storing global knowledge');
await this.qdrant.storeGlobalKnowledge(id, embedding, {
session_id: 'global',
content,
role: 'system',
timestamp: Date.now(),
...metadata,
});
}
/**
* Search for relevant memories using vector similarity
* Queries BOTH global (user_id="0") and user-specific memories
*/
async search(
userId: string,
queryEmbedding: number[],
options?: {
limit?: number;
sessionId?: string;
minScore?: number;
timeRange?: { start: number; end: number };
}
): Promise<SearchResult[]> {
const limit = options?.limit || 5;
const minScore = options?.minScore || 0.7;
this.logger.debug(
{ userId, limit, sessionId: options?.sessionId },
'Searching for relevant memories (global + user)'
);
// Qdrant client handles the "should" logic: user_id = userId OR user_id = "0"
const results = await this.qdrant.search(userId, queryEmbedding, {
limit,
scoreThreshold: minScore,
sessionId: options?.sessionId,
timeRange: options?.timeRange,
});
return results.map(r => ({
id: r.id,
score: r.score,
payload: r.payload as VectorPoint['payload'],
}));
}
/**
* Get recent conversation history for context
*/
async getRecentHistory(
userId: string,
sessionId: string,
limit: number = 10
): Promise<SearchResult[]> {
this.logger.debug({ userId, sessionId, limit }, 'Getting recent conversation history');
const result = await this.qdrant.scroll(userId, {
sessionId,
limit,
});
return result.points.map(p => ({
id: p.id,
score: 1.0, // Not a search result, so score is 1.0
payload: p.payload as VectorPoint['payload'],
}));
}
/**
* Delete all vectors for a user (GDPR compliance)
*/
async deleteUserData(userId: string): Promise<void> {
this.logger.info({ userId }, 'Deleting all user vectors for GDPR compliance');
await this.qdrant.deleteUserData(userId);
}
/**
* Delete all vectors for a session
*/
async deleteSession(userId: string, sessionId: string): Promise<void> {
this.logger.info({ userId, sessionId }, 'Deleting session vectors');
await this.qdrant.deleteSession(userId, sessionId);
}
/**
* Get collection statistics
*/
async getStats(): Promise<{
vectorCount: number;
indexedCount: number;
collectionSize: number;
}> {
const info = await this.qdrant.getCollectionInfo();
return {
vectorCount: info.vectorsCount,
indexedCount: info.indexedVectorsCount,
collectionSize: info.pointsCount,
};
}
}

View File

@@ -34,18 +34,6 @@ export interface WorkspaceContext {
preferences: Record<string, unknown>;
}
/**
* Memory chunk from RAG retrieval
*/
export interface MemoryChunk {
id: string;
content: string;
role: 'user' | 'assistant' | 'system';
timestamp: number;
relevanceScore: number;
metadata?: Record<string, unknown>;
}
/**
* Enhanced user context for agent harness
*
@@ -53,7 +41,6 @@ export interface MemoryChunk {
* - User identity and license
* - Active channel info (for multi-channel support)
* - Conversation state and history
* - RAG-retrieved relevant memories
* - Workspace state
*
* This object is passed to all agent nodes and tools.
@@ -71,9 +58,6 @@ export interface UserContext {
conversationHistory: BaseMessage[];
currentMessage?: string;
// RAG context
relevantMemories: MemoryChunk[];
// Workspace state
workspaceState: WorkspaceContext;
@@ -167,7 +151,6 @@ export function createUserContext(params: {
capabilities,
},
conversationHistory: [],
relevantMemories: [],
workspaceState: {
activeIndicators: [],
activeStrategies: [],

View File

@@ -1,219 +0,0 @@
# Dexorder AI Assistant System 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.
Your text responses should be markdown, using emojiis, color, and formatting to create a visually appealing response.
# User Information
**User License:** {{licenseType}}
**Available Features:**
{{features}}
# Platform Capabilities
Dexorder trading platform provides OHLC data at a 1-minute resolution and supports strategies that read one or more OHLC feeds. It also offers a wide range of built-in indicators and allows users to create custom indicators for advanced analysis. Custom strategies can be backtested and paper traded before live execution.
Dexorder does not support:
* tick-by-tick trading or high-frequency strategies.
* long-running computations like parameter optimizations or training machine learning models during live execution.
* portfolio optimization or trading strategies that require a large number of symbols.
* LLM calls inside strategy scripts — strategies must be deterministic and lightweight for backtesting to be reliable and repeatable. LLMs are slow, expensive, and introduce temperature-based non-determinism that breaks backtesting. (Walk-forward LLM integration via timer/data triggers is planned but not yet available.)
* TradFi data (equities, forex, bonds, options, etc.) — only crypto pricing data is available.
* Alternative data sources such as news feeds, Twitter/social sentiment, on-chain data, or economic calendars — these are not yet available.
Dexorder does support:
* backtesting strategies against historical data.
* multi-symbol comparisons.
* multi-timeframe analysis.
* custom indicators with plotting
* custom calculations and transformations.
* deep analysis and charting using Python libraries
If the user asks for a capability not provided by Dexorder, decline and explain our capabilities.
# Important Instructions
## Switching Chart Symbol or Timeframe
**IMPORTANT: When the user asks to switch, change, or update the chart symbol or timeframe, you MUST call `workspace_patch` directly. Do NOT use web_explore, do NOT delegate to the indicator tool.**
Call `workspace_patch` with `store_name = "chartState"` and the appropriate JSON patch:
To switch symbol only:
```json
[{ "op": "replace", "path": "/symbol", "value": "SOL/USDT.BINANCE" }]
```
To switch symbol and period (period is seconds: 60=1m, 300=5m, 900=15m, 3600=1h, 86400=1D):
```json
[
{ "op": "replace", "path": "/symbol", "value": "SOL/USDT.BINANCE" },
{ "op": "replace", "path": "/period", "value": 900 }
]
```
You already know this format — do not search for it. After patching, confirm the change to the user.
## Investment Advice
**NEVER** recommend any specific ticker, trade, or position. You may suggest mechanical adjustments or improvements to strategies, but you must **NEVER** offer an opinion on a specific trade or position. You are **NOT** a registered investment advisor.
## Task Delegation
- For ANY research questions, deep analysis, statistical analysis, charting requests, or market data queries that require computation, you MUST use the 'research' tool
- For ANYTHING related to indicators on the chart — reading, adding, removing, modifying, or creating custom indicators — you MUST use the 'indicator' tool
- For ANY request about trading strategies — writing, editing, backtesting, interpreting results, activating, deactivating, or monitoring — you MUST use the 'strategy' tool; NEVER write strategy Python code yourself
- NEVER write Python code directly in your responses to the user
- NEVER show code to the user — delegate to the research, indicator, or strategy tool instead
- NEVER attempt to do analysis yourself — let the subagents handle it
## Available Tools
### indicator
**Use this tool for all indicator-related requests.**
The indicator subagent manages the chart's indicators: it reads the current indicator set, adds or removes indicators, modifies parameters, and can create custom indicator scripts.
**ALWAYS use indicator for:**
- "What indicators do I have on the chart?" → read and describe current indicators
- "Show RSI" / "Add Bollinger Bands" → add indicators to chart
- "Change MACD fast period to 8" → modify indicator parameters
- "Remove all moving averages" → remove indicators
- "Create a custom volume-weighted RSI" → write custom indicator
- Any question about what an indicator means or how it's configured
- Recommending indicators for a given strategy
**Custom indicators vs. ad-hoc research scripts:**
When a user asks for a calculation (e.g. "volume-weighted RSI", "adaptive ATR", "sector relative strength"), prefer creating a **custom indicator** via this tool over writing a one-off pandas/Python script in the research tool. Custom indicators are better because:
1. **Reusable** — saved permanently and can be applied to any symbol at any time
2. **First-class UI** — appear in the chart's Indicator picker alongside built-in indicators
3. **Live chart display** — their values are plotted directly on the chart as the user browses
4. **Watchlist & trigger support** — can be used to filter symbols (watchlists) and fire alerts/triggers (coming soon)
Use the research tool for exploratory or one-off analysis. Use the indicator tool whenever the user wants to *track* or *reuse* a computed value.
**NEVER modify workspace indicators yourself** — always delegate to the indicator tool.
### web_explore
**Use this tool to search the web or academic databases.**
The web-explore subagent searches the web (or arXiv for academic topics), fetches relevant pages, and returns a markdown summary with cited sources.
**ALWAYS use web_explore for:**
- Questions about current events, news, or real-time information
- Documentation, tutorials, or how-to guides
- Academic papers, research findings, or scientific topics
- Any topic that requires up-to-date external sources
**NOT for market data or computation** — use the research tool for analysis, and get_chart_data for OHLC values.
### research
**This is your PRIMARY tool for data analysis, computation, and charting.**
Creates and runs Python research scripts via a specialized research subagent.
The subagent autonomously writes code, executes it, handles errors, and generates charts.
**ALWAYS use research for:**
- Any plotting, charting, or visualization requests
- Price action analysis and correlations
- Statistical analysis of market data
- Volume analysis and patterns
- Machine learning or predictive modeling
- Any data-intensive computations
- Multi-symbol comparisons
- Custom calculations or transformations
- Deep analysis requiring Python libraries (pandas, numpy, scipy, matplotlib, etc.)
**NOT for indicator management** — use the indicator tool for that.
**NEVER attempt to do analysis yourself in the chat.**
Let the research subagent write and execute the Python code.
Parameters:
- instruction: Natural language description of the analysis to perform (be specific!)
- name: A unique name for the research script (e.g., "BTC Weekly Analysis")
**Do NOT include any time range, history length, bar count, period size, resolution, or timestamp guidance in the instruction** — not as numbers, not as natural language ("3-6 months", "1 year", "sufficient data"), not at all. The research subagent has its own rules for selecting resolution and history window. If you add time guidance, the subagent will follow yours instead of its own (which uses much more data). Only pass time constraints if the user explicitly asked for a specific period (e.g. "last week", "show me 2023").
Example usage:
- User: "Does Friday price action correlate with Monday?"
- You: Call research tool with instruction="Analyze correlation between Friday and Monday price action during NY trading hours (9:30-4:00 ET)", name="Friday-Monday Correlation"
- WRONG: "...use hourly data and at least 3-6 months..." ← never add this
### strategy
**Use this tool for ALL trading strategy requests without exception.**
The strategy subagent handles the complete strategy lifecycle: writing PandasStrategy classes, running backtests, interpreting results, and activating/deactivating paper trading.
**ALWAYS use strategy for:**
- "Create a strategy that buys when RSI < 30" write a new strategy
- "Edit my momentum strategy to use a tighter stop" modify existing strategy
- "Backtest my RSI strategy over the last year" run backtest
- "How did this strategy perform on BTC?" interpret results
- "Activate my strategy for paper trading" start paper trading
- "What strategies are running?" list active strategies
- "Stop my momentum strategy" deactivate a strategy
- Any question about a strategy's PnL, trades, or performance
**NEVER call `backtest_strategy`, `activate_strategy`, `deactivate_strategy`, or `list_active_strategies` directly** always go through the strategy tool.
**Custom indicators in strategies:**
When writing a new strategy, the strategy subagent will first check for existing custom indicators via `python_list(category="indicator")`. Prefer using custom indicators (via `ta.custom_*`) over computing signals inline this promotes reuse and gives users better visibility into strategy components. If a needed indicator doesn't exist yet, the strategy subagent will create it first via the indicator workflow.
### backtest_strategy
*(Called internally by the strategy tool do not call this directly.)*
Runs a saved trading strategy against historical OHLC data using the Nautilus Trader backtesting engine.
Returns structured performance metrics including trade list, Sortino/Calmar ratios, and equity curve.
### list_active_strategies
*(Called internally by the strategy tool do not call this directly.)*
Lists all currently active (live or paper) strategies and their status.
### python_list
List existing scripts in a category ("strategy", "indicator", or "research").
Use this before calling the research tool to check whether a relevant script already exists.
If one does, pass its exact name to the research tool so the subagent updates it rather than creating a new one.
The strategy tool uses this internally to check strategy names before backtesting.
### symbol-lookup
Look up trading symbols and get metadata.
Use this when users mention tickers or need symbol information.
**Always use symbol_lookup to resolve a proper ticker before passing it to the research or get-chart-data tools.** Symbols must be in `SYMBOL.EXCHANGE` format (e.g., `BTC/USDT.BINANCE`). If the user says "ETHUSDT", "ETH", or any ambiguous ticker, resolve it first with symbol_lookup so the correct formatted ticker is passed downstream.
### get-chart-data
**IMPORTANT: This is for QUICK, CASUAL information ONLY. This tool just returns raw data - it does NOT create charts or plots.**
Use ONLY when the user wants to:
- Quickly glance at recent price data
- Get a rough sense of current market conditions
- Check basic OHLC values
- Retrieve raw data without any processing
**DO NOT use get-chart-data for:**
- Plotting, charting, or any visualization
- Statistical analysis or correlations
- Calculations or data transformations
- Multi-symbol comparisons
- Volume analysis or patterns
- Any non-trivial computation
- Technical indicators or overlays
**For anything beyond casual data retrieval, use the 'research' tool instead.**
The research tool can create proper analysis with charts, statistics, and computations.
**Time Parameters:** Both from_time and to_time accept:
- Unix timestamps as numbers (e.g., 1774126800)
- Unix timestamps as strings (e.g., "1774126800")
- Date strings (e.g., "2 days ago", "2024-01-01", "yesterday")
## Workspace Tools (MCP)
You also have access to workspace persistence tools via MCP:
- **workspace_read(store_name)**: Read a workspace store (returns JSON object)
- **workspace_write(store_name, data)**: Write/overwrite a workspace store
- **workspace_patch(store_name, patch)**: Apply JSON patch to a workspace store
These are useful for persisting user preferences, analysis results, and custom data across sessions.
For the `indicators` store specifically, always use the indicator tool rather than calling workspace tools directly.

View File

@@ -0,0 +1,4 @@
export { WikiLoader } from './wiki-loader.js';
export type { WikiFrontmatter, WikiPage, SpawnContext, VirtualPageFn } from './wiki-loader.js';
export { SpawnService } from './spawn-service.js';
export type { SpawnInput } from './spawn-service.js';

View File

@@ -0,0 +1,222 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
/** All platform tool names available to every subagent. */
const ALL_PLATFORM_TOOLS = ['SymbolLookup', 'GetChartData', 'WebSearch', 'FetchPage', 'ArxivSearch'];
import type { FastifyBaseLogger } from 'fastify';
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import type { HarnessEvent, SubagentChunkEvent, SubagentThinkingEvent } from '../harness-events.js';
import { getToolLabel } from '../tool-labels.js';
import type { MCPClientConnector } from '../mcp-client.js';
import type { WorkspaceManager } from '../../workspace/workspace-manager.js';
import type { ToolRegistry } from '../../tools/tool-registry.js';
import type { MCPToolInfo } from '../../tools/mcp/mcp-tool-wrapper.js';
import { WikiLoader, type SpawnContext } from './wiki-loader.js';
export interface SpawnInput {
agentName: string;
instruction: string;
mcpClient?: MCPClientConnector;
availableMCPTools?: MCPToolInfo[];
workspaceManager?: WorkspaceManager;
signal?: AbortSignal;
}
/**
* SpawnService creates isolated subagent invocations on demand.
*
* Each call to streamSpawn():
* 1. Loads the agent's wiki page (frontmatter + body)
* 2. Builds a SystemMessage from base prompt + agent body + static imports
* 3. Loads dynamic imports as a HumanMessage prefix (never cached)
* 4. Resolves tools from the frontmatter tool lists
* 5. Creates a fresh createReactAgent and streams events
*
* This replaces the old per-subagent BaseSubagent pattern with a stateless
* factory that reads configuration from markdown frontmatter.
*/
export class SpawnService {
constructor(
private readonly wikiLoader: WikiLoader,
private readonly toolRegistry: ToolRegistry,
private readonly modelFn: (maxTokens?: number) => Promise<BaseChatModel>,
private readonly logger: FastifyBaseLogger,
) {}
/**
* Stream events from a subagent invocation.
* Yields HarnessEvents (subagent_chunk, subagent_thinking, subagent_tool_call).
* Returns the final text result (or JSON with images when spawnsImages is set).
*/
async *streamSpawn(input: SpawnInput): AsyncGenerator<HarnessEvent, string> {
const { agentName, instruction, mcpClient, availableMCPTools, workspaceManager, signal } = input;
this.logger.info({ agentName, instruction: instruction.substring(0, 100) }, 'SpawnService: starting');
// Load agent wiki page
const agentPage = await this.wikiLoader.loadAgentPage(agentName);
const fm = agentPage.frontmatter;
// Build SpawnContext for virtual pages
const ctx: SpawnContext = { mcpClient, workspaceManager };
// Load base prompt (index.md + tools.md) — stable, tier-1 cacheable
const basePrompt = await this.wikiLoader.getBasePrompt();
// Load static imports (appended to agent body, tier-2 cacheable together)
const staticImports = fm.static_imports?.length
? await this.wikiLoader.loadStaticImports(fm.static_imports)
: '';
// Build the static SystemMessage (base + agent body + static imports)
const staticContent = [basePrompt, agentPage.body, staticImports]
.filter(Boolean)
.join('\n\n---\n\n');
const systemMessage = new SystemMessage(staticContent);
// Load dynamic imports (never cached, injected as a HumanMessage prefix)
const dynamicContent = fm.dynamic_imports?.length
? await this.wikiLoader.loadDynamicImports(fm.dynamic_imports, ctx)
: '';
// Build HumanMessage: dynamic context (if any) + instruction
const humanContent = dynamicContent
? `${dynamicContent}\n\n---\n\n${instruction}`
: instruction;
const humanMessage = new HumanMessage(humanContent);
// Set up image capture array (per-call, not shared mutable state)
const imageCapture: Array<{ data: string; mimeType: string }> = [];
const onImage = fm.spawnsImages
? (img: { data: string; mimeType: string }) => imageCapture.push(img)
: undefined;
const onWorkspaceMutation = workspaceManager
? (storeName: string, newState: unknown) => {
workspaceManager.setState(storeName, newState).catch((err: Error) => {
this.logger.error({ err, storeName }, 'Failed to sync workspace after spawn mutation');
});
}
: undefined;
// All subagents get all platform tools and all MCP tools.
// Per-agent tool restrictions via frontmatter are no longer used.
const tools = await this.toolRegistry.resolveTools(
ALL_PLATFORM_TOOLS,
['*'],
mcpClient,
availableMCPTools,
workspaceManager,
onImage,
onWorkspaceMutation,
);
this.logger.info(
{ agentName, toolCount: tools.length, toolNames: tools.map(t => t.name) },
'SpawnService: tools resolved'
);
// Create model (respecting per-agent maxTokens)
const model = await this.modelFn(fm.maxTokens);
// Create a fresh ReactAgent for this invocation
const agent = createReactAgent({
llm: model,
tools,
prompt: systemMessage,
});
const recursionLimit = fm.recursionLimit ?? 30;
// Emit an initial indicator so the UI shows the subagent has started
yield { type: 'subagent_tool_call', agentName, toolName: 'Thinking...', label: 'Thinking...' };
const stream = agent.stream(
{ messages: [humanMessage] },
{ streamMode: ['messages', 'updates'], recursionLimit, signal }
);
let finalText = '';
for await (const [mode, data] of await stream) {
if (signal?.aborted) break;
if (mode === 'messages') {
for (const chunk of SpawnService.extractStreamChunks(data, agentName)) {
yield chunk;
}
} else if (mode === 'updates') {
if ((data as any).agent?.messages) {
for (const msg of (data as any).agent.messages as any[]) {
if (msg.tool_calls?.length) {
for (const tc of msg.tool_calls) {
yield {
type: 'subagent_tool_call',
agentName,
toolName: tc.name,
label: getToolLabel(tc.name),
};
}
} else {
const content = SpawnService.extractFinalText(msg);
if (content) finalText = content;
}
}
}
}
}
this.logger.info(
{ agentName, textLength: finalText.length, imageCount: imageCapture.length },
'SpawnService: finished'
);
// If this agent captures images, return JSON with text + images
if (fm.spawnsImages && imageCapture.length > 0) {
return JSON.stringify({ text: finalText, images: imageCapture });
}
return finalText;
}
/**
* Extract subagent_chunk / subagent_thinking events from a LangGraph `messages` stream datum.
*/
static extractStreamChunks(
data: unknown,
agentName: string,
): Array<SubagentChunkEvent | SubagentThinkingEvent> {
const msg = Array.isArray(data) ? (data as unknown[])[0] : data;
const content = (msg as any)?.content;
if (typeof content === 'string') {
return content ? [{ type: 'subagent_chunk', agentName, content }] : [];
}
if (Array.isArray(content)) {
const chunks: Array<SubagentChunkEvent | SubagentThinkingEvent> = [];
for (const block of content as any[]) {
if (block?.type === 'thinking' && typeof block.thinking === 'string' && block.thinking) {
chunks.push({ type: 'subagent_thinking', agentName, content: block.thinking });
} else if (block?.type === 'text' && typeof block.text === 'string' && block.text) {
chunks.push({ type: 'subagent_chunk', agentName, content: block.text });
}
}
return chunks;
}
return [];
}
/**
* Extract the final text from an `updates`-mode agent message.
*/
static extractFinalText(msg: any): string {
if (typeof msg?.content === 'string') return msg.content;
if (Array.isArray(msg?.content)) {
return (msg.content as any[])
.filter((b: any) => b?.type === 'text' && typeof b.text === 'string')
.map((b: any) => b.text as string)
.join('');
}
return '';
}
}

View File

@@ -0,0 +1,204 @@
import { readFile, readdir } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import yaml from 'js-yaml';
import type { MCPClientConnector } from '../mcp-client.js';
import type { WorkspaceManager } from '../../workspace/workspace-manager.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Knowledge directory: gateway/knowledge/ (3 levels up from harness/spawn/)
const KNOWLEDGE_DIR = join(__dirname, '..', '..', '..', 'knowledge');
// Prompt directory: gateway/prompt/ — base prompts and agent system prompts
// These are injected directly into context and are not surfaced via memory_lookup.
const PROMPT_DIR = join(__dirname, '..', '..', '..', 'prompt');
export interface WikiFrontmatter {
maxTokens?: number;
recursionLimit?: number;
spawnsImages?: boolean;
static_imports?: string[];
dynamic_imports?: string[];
}
export interface WikiPage {
frontmatter: WikiFrontmatter;
body: string;
}
export interface SpawnContext {
mcpClient?: MCPClientConnector;
workspaceManager?: WorkspaceManager;
}
export type VirtualPageFn = (ctx: SpawnContext) => Promise<string>;
/**
* WikiLoader loads markdown knowledge pages from disk, caches them in memory,
* and supports virtual pages that are generated dynamically at spawn time.
*
* Page resolution order: virtual registry first, then disk.
*/
export class WikiLoader {
private readonly pageCache = new Map<string, WikiPage>();
private readonly virtualPages = new Map<string, VirtualPageFn>();
private basePromptCache: string | null = null;
/**
* Register a virtual page that generates markdown dynamically.
* Virtual pages are never cached — they run fresh every time they are imported.
*/
registerVirtual(name: string, fn: VirtualPageFn): void {
this.virtualPages.set(name, fn);
}
/**
* Load and cache a wiki page from disk.
* Looks in KNOWLEDGE_DIR only — prompt files are not surfaced here.
* Returns null if the file does not exist.
*/
async loadPage(name: string): Promise<WikiPage | null> {
if (this.pageCache.has(name)) {
return this.pageCache.get(name)!;
}
const filePath = join(KNOWLEDGE_DIR, `${name}.md`);
let content: string;
try {
content = await readFile(filePath, 'utf-8');
} catch {
return null;
}
const page = this.parsePage(content);
this.pageCache.set(name, page);
return page;
}
/**
* Load a prompt file from PROMPT_DIR (never exposed via memory_lookup).
* Returns null if the file does not exist.
*/
private async loadPromptPage(name: string): Promise<WikiPage | null> {
const cacheKey = `__prompt__${name}`;
if (this.pageCache.has(cacheKey)) {
return this.pageCache.get(cacheKey)!;
}
const filePath = join(PROMPT_DIR, `${name}.md`);
let content: string;
try {
content = await readFile(filePath, 'utf-8');
} catch {
return null;
}
const page = this.parsePage(content);
this.pageCache.set(cacheKey, page);
return page;
}
/**
* Load an agent-specific page from PROMPT_DIR (throws if not found).
*/
async loadAgentPage(agentName: string): Promise<WikiPage> {
const page = await this.loadPromptPage(`agent-${agentName}`);
if (!page) {
throw new Error(`Agent prompt page not found: prompt/agent-${agentName}.md`);
}
return page;
}
/**
* Load static imports (real .md files) and concatenate their bodies.
* Results are cached via individual page caches.
*/
async loadStaticImports(names: string[]): Promise<string> {
const parts: string[] = [];
for (const name of names) {
const page = await this.loadPage(name);
if (page) {
parts.push(page.body);
}
}
return parts.join('\n\n---\n\n');
}
/**
* Load dynamic imports (virtual pages or disk fallback, never cached).
* Failures are non-fatal and logged to stderr.
*/
async loadDynamicImports(names: string[], ctx: SpawnContext): Promise<string> {
const parts: string[] = [];
for (const name of names) {
const fn = this.virtualPages.get(name);
if (fn) {
try {
const content = await fn(ctx);
if (content) parts.push(content);
} catch (err) {
console.warn(`[WikiLoader] Virtual page '${name}' failed:`, err);
}
} else {
// Fall back to disk (dynamic import, so bypass the in-memory cache)
const filePath = join(KNOWLEDGE_DIR, `${name}.md`);
try {
const raw = await readFile(filePath, 'utf-8');
const page = this.parsePage(raw);
parts.push(page.body);
} catch {
console.warn(`[WikiLoader] Dynamic page '${name}' not found on disk or in registry`);
}
}
}
return parts.join('\n\n---\n\n');
}
/**
* Return the base prompt text: prompt/index.md body + prompt/tools.md body, concatenated.
* Result is cached for the lifetime of this WikiLoader instance.
*/
async getBasePrompt(): Promise<string> {
if (this.basePromptCache !== null) return this.basePromptCache;
const [index, tools] = await Promise.all([
this.loadPromptPage('index'),
this.loadPromptPage('tools'),
]);
const parts: string[] = [];
if (index) parts.push(index.body);
if (tools) parts.push(tools.body);
this.basePromptCache = parts.join('\n\n');
return this.basePromptCache;
}
/**
* List all .md file names available in the knowledge directory (without extension).
*/
async listPages(): Promise<string[]> {
try {
const files = await readdir(KNOWLEDGE_DIR);
return files
.filter(f => f.endsWith('.md'))
.map(f => f.slice(0, -3));
} catch {
return [];
}
}
/**
* Parse a markdown file with optional YAML frontmatter.
*/
private parsePage(content: string): WikiPage {
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (fmMatch) {
const frontmatter = (yaml.load(fmMatch[1]) as WikiFrontmatter) ?? {};
const body = fmMatch[2].trim();
return { frontmatter, body };
}
return { frontmatter: {}, body: content.trim() };
}
}

View File

@@ -1,273 +0,0 @@
# Subagents
Specialized agents with dedicated knowledge bases and system prompts.
## What are Subagents?
Subagents are focused AI agents designed for specific tasks. Unlike general-purpose agents, each subagent has:
- **Specialized knowledge**: Multi-file memory directory with domain-specific info
- **Custom system prompt**: Tailored instructions for the task
- **Model override**: Can use different models than the main agent
- **Capability tags**: Declare what they can do
## Directory Structure
```
subagents/
├── base-subagent.ts # Base class
├── {subagent-name}/
│ ├── config.yaml # Configuration
│ ├── system-prompt.md # System instructions
│ ├── memory/ # Knowledge base (multi-file)
│ │ ├── file1.md
│ │ ├── file2.md
│ │ └── file3.md
│ └── index.ts # Implementation
└── README.md # This file
```
## Creating a New Subagent
### 1. Create Directory Structure
```bash
mkdir -p subagents/my-subagent/memory
```
### 2. Create config.yaml
```yaml
name: my-subagent
description: What it does
# Model override (optional)
model: claude-sonnet-4-6
temperature: 0.3
maxTokens: 4096
# Memory files to load
memoryFiles:
- guidelines.md
- examples.md
- best-practices.md
# System prompt file
systemPromptFile: system-prompt.md
# Capabilities
capabilities:
- capability1
- capability2
```
### 3. Write system-prompt.md
```markdown
# My Subagent System Prompt
You are an expert in [domain].
## Your Role
[What the subagent does]
## Approach
1. [Step 1]
2. [Step 2]
## Output Format
[How to structure responses]
```
### 4. Create Memory Files
Split knowledge into logical files:
```markdown
<!-- memory/guidelines.md -->
# Guidelines
## What to Check
- Thing 1
- Thing 2
## What to Avoid
- Anti-pattern 1
- Anti-pattern 2
```
### 5. Implement Subagent
```typescript
// index.ts
import { BaseSubagent, SubagentConfig, SubagentContext } from '../base-subagent.js';
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { FastifyBaseLogger } from 'fastify';
export class MySubagent extends BaseSubagent {
constructor(config: SubagentConfig, model: BaseChatModel, logger: FastifyBaseLogger) {
super(config, model, logger);
}
async execute(context: SubagentContext, input: string): Promise<string> {
this.logger.info({ subagent: this.getName() }, 'Executing subagent');
const messages = this.buildMessages(context, input);
const response = await this.model.invoke(messages);
return response.content as string;
}
}
// Factory function
export async function createMySubagent(
model: BaseChatModel,
logger: FastifyBaseLogger,
basePath: string
): Promise<MySubagent> {
const { readFile } = await import('fs/promises');
const { join } = await import('path');
const yaml = await import('js-yaml');
const configPath = join(basePath, 'config.yaml');
const configContent = await readFile(configPath, 'utf-8');
const config = yaml.load(configContent) as SubagentConfig;
const subagent = new MySubagent(config, model, logger);
await subagent.initialize(basePath);
return subagent;
}
```
### 6. Export from index.ts
```typescript
// subagents/index.ts
export { MySubagent, createMySubagent } from './my-subagent/index.js';
```
## Using Subagents
### Direct Usage
```typescript
import { createMySubagent } from './harness/subagents';
const subagent = await createMySubagent(model, logger, basePath);
const result = await subagent.execute({ userContext }, 'input text');
```
### In Workflows
```typescript
const analyzeNode = async (state) => {
const result = await mySubagent.execute(
{ userContext: state.userContext },
state.input
);
return { analysis: result };
};
```
### With Routing
Add to `config/subagent-routing.yaml`:
```yaml
subagents:
my-subagent:
enabled: true
path: src/harness/subagents/my-subagent
triggers:
keywords:
- "keyword1"
- "keyword2"
patterns:
- "pattern.*regex"
priority: medium
timeout: 30000
```
## Multi-File Memory Benefits
### Why Split Memory?
1. **Organization**: Easier to maintain separate concerns
2. **Versioning**: Update specific files without touching others
3. **Collaboration**: Multiple people can work on different files
4. **Context Management**: LLM sees structured knowledge
### Example Split
For a code reviewer:
- `review-guidelines.md`: What to check
- `common-patterns.md`: Good/bad examples
- `best-practices.md`: Industry standards
All files are loaded and concatenated at initialization.
## Best Practices
### Memory Files
- **Be Specific**: Include concrete examples, not just theory
- **Use Markdown**: Tables, lists, code blocks for clarity
- **Keep Focused**: Each file should have a clear purpose
- **Update Regularly**: Improve based on real usage
### System Prompts
- **Define Role Clearly**: "You are a [specific role]"
- **Specify Output Format**: Show examples of expected output
- **Set Constraints**: What to do, what not to do
- **Give Context**: Why this subagent exists
### Configuration
- **Model Selection**: Use faster models for simple tasks
- **Temperature**: Lower (0.2-0.3) for precise work, higher (0.7-0.9) for creative
- **Capabilities**: Tag accurately for routing
## Available Subagents
### code-reviewer
Reviews trading strategy code for bugs, performance, and best practices.
**Capabilities:**
- `static_analysis`
- `performance_review`
- `security_audit`
- `code_quality`
**Memory:**
- Review guidelines
- Common patterns
- Best practices
### risk-analyzer (TODO)
Analyzes trading risk and exposure.
### market-analyst (TODO)
Provides market analysis and insights.
## Troubleshooting
### Memory Files Not Loading
- Check file paths in config.yaml
- Ensure files exist in memory/ directory
- Check file permissions
### Subagent Not Being Routed
- Verify triggers in subagent-routing.yaml
- Check priority (higher priority matches first)
- Ensure enabled: true
### Model Errors
- Verify API keys in environment
- Check model override is valid
- Ensure token limits not exceeded

View File

@@ -1,447 +0,0 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { BaseMessage } from '@langchain/core/messages';
import { SystemMessage, HumanMessage } from '@langchain/core/messages';
import type { FastifyBaseLogger } from 'fastify';
import type { UserContext } from '../memory/session-context.js';
import type { MCPClientConnector } from '../mcp-client.js';
import type { DynamicStructuredTool } from '@langchain/core/tools';
import { readFile } from 'fs/promises';
import { join } from 'path';
import type { HarnessEvent, SubagentChunkEvent, SubagentThinkingEvent } from '../harness-events.js';
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import yaml from 'js-yaml';
/**
* Subagent configuration (loaded from config.yaml)
*/
export interface SubagentConfig {
name: string;
model?: string; // Override default model
temperature?: number;
maxTokens?: number;
memoryFiles: string[]; // Memory files to load from memory/ directory
capabilities: string[];
systemPromptFile?: string; // Path to system-prompt.md
tools?: {
platform?: string[]; // Platform tool names
mcp?: string[]; // MCP tool patterns/names
};
}
/**
* Subagent execution context
*/
export interface SubagentContext {
userContext: UserContext;
conversationHistory?: BaseMessage[];
}
/**
* Base subagent class
*
* Subagents are specialized agents with:
* - Dedicated system prompts
* - Multi-file memory (guidelines, patterns, best practices)
* - Optional model override
* - Specific capabilities
*
* Structure:
* subagents/
* research/
* config.yaml
* system-prompt.md
* index.ts
*/
export abstract class BaseSubagent {
protected logger: FastifyBaseLogger;
protected model: BaseChatModel;
protected config: SubagentConfig;
protected systemPrompt?: string;
protected memoryContext: string[] = [];
protected mcpClient?: MCPClientConnector;
protected tools: DynamicStructuredTool[] = [];
constructor(
config: SubagentConfig,
model: BaseChatModel,
logger: FastifyBaseLogger,
mcpClient?: MCPClientConnector,
tools?: DynamicStructuredTool[]
) {
this.config = config;
this.model = model;
this.logger = logger;
this.mcpClient = mcpClient;
this.tools = tools || [];
}
/** Per-subagent recursion limit for the LangGraph agent loop */
protected abstract getRecursionLimit(): number;
/** Fallback text returned when the agent produces no output */
protected abstract getFallbackText(): string;
/** Whether an MCP client is required; defaults true. Override to false for tool-only subagents. */
protected requiresMCPClient(): boolean {
return true;
}
/**
* Build the system message and final human message for agent invocation.
* Subclasses may override to augment the system message (e.g. injecting dynamic context).
*/
protected async buildSystemMessage(
context: SubagentContext,
instruction: string
): Promise<{ systemMessage: SystemMessage; humanMessage: BaseMessage }> {
const msgs = this.buildMessages(context, instruction);
return {
systemMessage: msgs[0] as SystemMessage,
humanMessage: msgs[msgs.length - 1],
};
}
/**
* Shared execute body. Subclasses that need pre/post hooks (e.g. image capture)
* override execute() and call this method internally.
*/
protected async executeAgent(context: SubagentContext, instruction: string): Promise<string> {
this.logger.info(
{
subagent: this.getName(),
userId: context.userContext.userId,
instruction: instruction.substring(0, 200),
toolCount: this.tools.length,
toolNames: this.tools.map(t => t.name),
},
`${this.config.name} subagent starting`
);
if (this.requiresMCPClient() && !this.hasMCPClient()) {
throw new Error(`MCP client not available for ${this.config.name} subagent`);
}
if (this.requiresMCPClient() && this.tools.length === 0) {
this.logger.warn(`${this.config.name} subagent has no tools`);
}
const { systemMessage, humanMessage } = await this.buildSystemMessage(context, instruction);
const agent = createReactAgent({
llm: this.model,
tools: this.tools,
prompt: systemMessage,
});
const result = await agent.invoke(
{ messages: [humanMessage] },
{ recursionLimit: this.getRecursionLimit() }
);
const allMessages: any[] = result.messages ?? [];
this.logger.info(
{ messageCount: allMessages.length },
`${this.config.name} subagent graph completed`
);
const lastAI = [...allMessages].reverse().find(
(m: any) => m.constructor?.name === 'AIMessage' || m._getType?.() === 'ai'
);
const finalText = lastAI
? (typeof lastAI.content === 'string' ? lastAI.content : JSON.stringify(lastAI.content))
: this.getFallbackText();
this.logger.info({ textLength: finalText.length }, `${this.config.name} subagent finished`);
return finalText;
}
/**
* Execute subagent. Delegates to executeAgent by default;
* subclasses with side-effects (image capture, etc.) override this.
*/
async execute(context: SubagentContext, instruction: string): Promise<string> {
return this.executeAgent(context, instruction);
}
/**
* Shared streamEvents loop. Subclasses that need pre/post hooks override streamEvents()
* and delegate here via `yield* this.streamEventsCore(...)`.
*/
protected async *streamEventsCore(
context: SubagentContext,
instruction: string,
signal?: AbortSignal,
): AsyncGenerator<HarnessEvent, string> {
this.logger.info({ subagent: this.getName() }, 'streamEvents starting');
if (this.requiresMCPClient() && !this.hasMCPClient()) {
throw new Error(`MCP client not available for ${this.config.name} subagent`);
}
const { systemMessage, humanMessage } = await this.buildSystemMessage(context, instruction);
const agent = createReactAgent({
llm: this.model,
tools: this.tools,
prompt: systemMessage,
});
const stream = agent.stream(
{ messages: [humanMessage] },
{ streamMode: ['messages', 'updates'], recursionLimit: this.getRecursionLimit(), signal }
);
let finalText = '';
for await (const [mode, data] of await stream) {
if (signal?.aborted) break;
if (mode === 'messages') {
for (const chunk of BaseSubagent.extractStreamChunks(data, this.config.name)) {
yield chunk;
}
} else if (mode === 'updates') {
if ((data as any).agent?.messages) {
for (const msg of (data as any).agent.messages as any[]) {
if (msg.tool_calls?.length) {
for (const tc of msg.tool_calls) {
yield { type: 'subagent_tool_call', agentName: this.config.name, toolName: tc.name, label: tc.name };
}
} else {
const content = BaseSubagent.extractFinalText(msg);
if (content) finalText = content;
}
}
}
}
}
this.logger.info({ textLength: finalText.length }, 'streamEvents finished');
return finalText;
}
/**
* Stream typed HarnessEvents during execution. Delegates to streamEventsCore by default.
* Subclasses with pre/post logic override this and use `yield* this.streamEventsCore(...)`.
*/
async *streamEvents(
context: SubagentContext,
input: string,
signal?: AbortSignal,
): AsyncGenerator<HarnessEvent, string> {
return yield* this.streamEventsCore(context, input, signal);
}
/**
* Stream execution (optional, default to non-streaming)
*/
async *stream(
context: SubagentContext,
input: string
): AsyncGenerator<string> {
const result = await this.execute(context, input);
yield result;
}
/**
* Extract subagent_chunk / subagent_thinking events from a LangGraph `messages` stream datum.
*
* LangGraph emits `[message_chunk, metadata]` tuples in `messages` mode. The message content
* can be a plain string (normal text token) or an array of content blocks (extended thinking
* responses with `{type:"thinking", thinking:"..."}` and `{type:"text", text:"..."}`).
*/
static extractStreamChunks(
data: unknown,
agentName: string,
): Array<SubagentChunkEvent | SubagentThinkingEvent> {
const msg = Array.isArray(data) ? (data as unknown[])[0] : data;
const content = (msg as any)?.content;
if (typeof content === 'string') {
return content ? [{ type: 'subagent_chunk', agentName, content }] : [];
}
if (Array.isArray(content)) {
const chunks: Array<SubagentChunkEvent | SubagentThinkingEvent> = [];
for (const block of content as any[]) {
if (block?.type === 'thinking' && typeof block.thinking === 'string' && block.thinking) {
chunks.push({ type: 'subagent_thinking', agentName, content: block.thinking });
} else if (block?.type === 'text' && typeof block.text === 'string' && block.text) {
chunks.push({ type: 'subagent_chunk', agentName, content: block.text });
}
}
return chunks;
}
return [];
}
/**
* Extract the final text from an `updates`-mode agent message.
* Handles both plain string content and array content blocks (extended thinking).
*/
static extractFinalText(msg: any): string {
if (typeof msg?.content === 'string') return msg.content;
if (Array.isArray(msg?.content)) {
return (msg.content as any[])
.filter((b: any) => b?.type === 'text' && typeof b.text === 'string')
.map((b: any) => b.text as string)
.join('');
}
return '';
}
/**
* Initialize subagent: load system prompt and memory files
*/
async initialize(basePath: string): Promise<void> {
this.logger.info({ subagent: this.config.name }, 'Initializing subagent');
// Load system prompt
if (this.config.systemPromptFile) {
const promptPath = join(basePath, this.config.systemPromptFile);
this.systemPrompt = await this.loadFile(promptPath);
}
// Load memory files
for (const memoryFile of this.config.memoryFiles) {
const memoryPath = join(basePath, 'memory', memoryFile);
const content = await this.loadFile(memoryPath);
if (content) {
this.memoryContext.push(`# ${memoryFile}\n\n${content}`);
}
}
this.logger.info(
{
subagent: this.config.name,
memoryFiles: this.config.memoryFiles.length,
systemPromptLoaded: !!this.systemPrompt,
},
'Subagent initialized'
);
}
/**
* Load config.yaml from basePath and parse it.
*/
static async loadConfig(basePath: string): Promise<SubagentConfig> {
const configContent = await readFile(join(basePath, 'config.yaml'), 'utf-8');
return yaml.load(configContent) as SubagentConfig;
}
/**
* Build messages with system prompt and memory context
*/
protected buildMessages(
context: SubagentContext,
currentInput: string
): BaseMessage[] {
const messages: BaseMessage[] = [];
// System prompt with memory context
let systemContent = this.systemPrompt || `You are ${this.config.name}.`;
if (this.memoryContext.length > 0) {
systemContent += '\n\n# Knowledge Base\n\n';
systemContent += this.memoryContext.join('\n\n---\n\n');
}
messages.push(new SystemMessage(systemContent));
// Add conversation history if provided
if (context.conversationHistory && context.conversationHistory.length > 0) {
messages.push(...context.conversationHistory);
}
// Add current input
messages.push(new HumanMessage(currentInput));
return messages;
}
/**
* Load file content
*/
private async loadFile(path: string): Promise<string | undefined> {
try {
const content = await readFile(path, 'utf-8');
return content;
} catch (error) {
this.logger.warn({ error, path }, 'Failed to load file');
return undefined;
}
}
/**
* Get subagent name
*/
getName(): string {
return this.config.name;
}
/**
* Get subagent capabilities
*/
getCapabilities(): string[] {
return this.config.capabilities;
}
/**
* Check if subagent has a specific capability
*/
hasCapability(capability: string): boolean {
return this.config.capabilities.includes(capability);
}
/**
* Call a tool on the user's MCP server
*
* @param name Tool name
* @param args Tool arguments
* @returns Tool result
* @throws Error if MCP client not available or tool call fails
*/
protected async callMCPTool(name: string, args: Record<string, unknown>): Promise<unknown> {
if (!this.mcpClient) {
throw new Error('MCP client not available for this subagent');
}
try {
this.logger.debug({ tool: name, args }, 'Calling MCP tool from subagent');
const result = await this.mcpClient.callTool(name, args);
return result;
} catch (error) {
this.logger.error({ error, tool: name }, 'MCP tool call failed');
throw error;
}
}
/**
* Check if MCP client is available
*/
protected hasMCPClient(): boolean {
return this.mcpClient !== undefined;
}
/**
* Get tools available to this subagent
*/
getTools(): DynamicStructuredTool[] {
return this.tools;
}
/**
* Set tools for this subagent (used during initialization)
*/
setTools(tools: DynamicStructuredTool[]): void {
this.tools = tools;
this.logger.debug(
{
subagent: this.config.name,
toolCount: tools.length,
toolNames: tools.map(t => t.name),
},
'Tools set for subagent'
);
}
}

View File

@@ -1,18 +0,0 @@
// Subagents exports
export {
BaseSubagent,
type SubagentConfig,
type SubagentContext,
} from './base-subagent.js';
export {
ResearchSubagent,
createResearchSubagent,
type ResearchResult,
} from './research/index.js';
export {
StrategySubagent,
createStrategySubagent,
} from './strategy/index.js';

View File

@@ -1,30 +0,0 @@
# Indicator Subagent Configuration
name: indicator
description: Manages TradingView indicators in the workspace and creates custom indicator scripts
# Model configuration
model: claude-sonnet-4-6
temperature: 0.3
maxTokens: 8192
# No memory files — all indicator knowledge is inline in the system prompt
memoryFiles: []
# System prompt file
systemPromptFile: system-prompt.md
# Capabilities this subagent provides
capabilities:
- indicator_management
- workspace_manipulation
- custom_indicators
# Tools available to this subagent
tools:
platform: []
mcp:
- workspace_read # Read current indicators store
- workspace_patch # Add/update/remove indicators (no workspace_write — patch only)
- category_* # Write/edit/read/list custom indicator scripts
- evaluate_indicator # Evaluate any indicator against real OHLC data

View File

@@ -1,34 +0,0 @@
import { BaseSubagent } from '../base-subagent.js';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { FastifyBaseLogger } from 'fastify';
import type { MCPClientConnector } from '../../mcp-client.js';
/**
* Indicator Subagent
*
* Specialized agent for managing TradingView indicators in the workspace.
* Uses workspace_read/patch MCP tools to:
* - Read, add, modify, and remove indicators from the indicators store
* - Create custom indicator scripts via python_* tools
* - Validate indicators using the evaluate_indicator tool
*/
export class IndicatorSubagent extends BaseSubagent {
protected getRecursionLimit() { return 25; }
protected getFallbackText() { return 'Indicator update completed.'; }
}
/**
* Factory function to create and initialize IndicatorSubagent
*/
export async function createIndicatorSubagent(
model: BaseChatModel,
logger: FastifyBaseLogger,
basePath: string,
mcpClient?: MCPClientConnector,
tools?: any[]
): Promise<IndicatorSubagent> {
const config = await BaseSubagent.loadConfig(basePath);
const subagent = new IndicatorSubagent(config, model, logger, mcpClient, tools);
await subagent.initialize(basePath);
return subagent;
}

View File

@@ -1,500 +0,0 @@
# Indicator Subagent
You are a specialized assistant that manages technical indicators on the Dexorder TradingView chart. You read and modify the `indicators` workspace store and can create custom indicator scripts.
---
## Section A — Available Standard Indicators
These are all indicators supported by the TradingView web client. The `pandas_ta_name` column is the exact value to use in the workspace store.
### Overlap / Moving Averages (plotted on price pane)
| `pandas_ta_name` | Display Name | Key Parameters | Description & Interpretation |
|------------------|--------------|----------------|-------------------------------|
| `sma` | Simple MA | `length=20` | Arithmetic mean of close over `length` periods. Lags price; crossovers used as trend signals. |
| `ema` | Exponential MA | `length=20` | Exponentially weighted MA — more weight on recent prices than SMA. Reacts faster. |
| `wma` | Weighted MA | `length=20` | Linearly increasing weights (most recent = highest weight). Between SMA and EMA in responsiveness. |
| `dema` | Double EMA | `length=20` | Two layers of EMA to reduce lag. More responsive than EMA, more noise at extremes. |
| `tema` | Triple EMA | `length=20` | Three EMA layers — lowest lag of the pure EMA family. Very sensitive to recent price. |
| `trima` | Triangular MA | `length=20` | Double-smoothed SMA; most weight on middle of the period. Very smooth, significant lag. |
| `kama` | Kaufman Adaptive MA | `length=10, fast=2, slow=30` | Adapts speed to market efficiency ratio — fast in trends, slow in chop. |
| `t3` | T3 MA | `length=5, a=0.7` | Tillson's smooth, low-lag MA using six EMAs. `a` controls smoothing vs lag trade-off. |
| `hma` | Hull MA | `length=20` | Very low-lag MA using weighted MAs. Designed to minimize lag while maintaining smoothness. |
| `alma` | Arnaud Legoux MA | `length=20, sigma=6, offset=0.85` | Gaussian-weighted MA; `offset` shifts weight toward recent (1.0) or past (0.0). |
| `midpoint` | Midpoint | `length=14` | `(highest_close + lowest_close) / 2` over `length` periods. Simple center of range. |
| `midprice` | Midprice | `length=14` | `(highest_high + lowest_low) / 2` over `length` periods. True price range midpoint. |
| `supertrend` | SuperTrend | `length=7, multiplier=3.0` | ATR-based trend band that flips above/below price. Direction signal; not a smooth line. |
| `ichimoku` | Ichimoku Cloud | `tenkan=9, kijun=26, senkou=52` | Multi-component Japanese system: Tenkan (fast), Kijun (slow), Senkou A/B (cloud), Chikou. |
| `vwap` | VWAP | `anchor='D'` | Volume-weighted average price, resets each `anchor` period. Benchmark for intraday value. Requires datetime index. |
| `vwma` | Volume-Weighted MA | `length=20` | Like SMA but candles weighted by volume — high-volume bars pull price harder. |
| `bbands` | Bollinger Bands | `length=20, std=2.0` | SMA ± N standard deviations. Returns upper, mid, lower bands. Squeeze = low vol; expansion = breakout. |
### Momentum (plotted in separate pane)
| `pandas_ta_name` | Display Name | Key Parameters | Description & Interpretation |
|------------------|--------------|----------------|-------------------------------|
| `rsi` | RSI | `length=14` | 0100 oscillator. >70 overbought, <30 oversold. Divergences from price signal reversals. |
| `macd` | MACD | `fast=12, slow=26, signal=9` | EMA difference (MACD line), signal line EMA, histogram. Crossovers and zero-line crosses are signals. |
| `stoch` | Stochastic | `k=14, d=3, smooth_k=3` | %K measures close vs recent range; %D is smoothed %K. >80 overbought, <20 oversold. |
| `stochrsi` | Stochastic RSI | `length=14, rsi_length=14, k=3, d=3` | Applies stochastic formula to RSI more sensitive than RSI alone. |
| `cci` | CCI | `length=20` | Deviation of price from statistical mean. ±100 are typical overbought/sold thresholds. |
| `willr` | Williams %R | `length=14` | Inverse stochastic, 100 to 0. Above 20 overbought, below 80 oversold. |
| `mom` | Momentum | `length=10` | Raw price difference: `close - close[n]`. Zero-line crossovers indicate direction change. |
| `roc` | Rate of Change | `length=10` | Percentage price change over `length` bars. Similar to momentum but normalized. |
| `trix` | TRIX | `length=18, signal=9` | 1-period % change of triple-smoothed EMA. Zero-line crossovers; filters noise well. |
| `cmo` | Chande MO | `length=14` | Ratio of up/down momentum, 100 to 100. Similar to RSI but uses all price changes. |
| `adx` | ADX | `length=14` | Trend strength 0100 (direction-agnostic). >25 = trending, <20 = ranging. Includes +DI/DI. |
| `aroon` | Aroon | `length=25` | Measures recency of highest/lowest prices. Aroon Up >70 and Down <30 = uptrend. |
| `ao` | Awesome Oscillator | *(no params)* | 5- vs 34-period SMA of midprice. Histogram above zero = bullish; below = bearish. |
| `bop` | Balance of Power | *(no params)* | `(close open) / (high low)`. Measures intrabar buying vs selling pressure. |
| `uo` | Ultimate Oscillator | `fast=7, medium=14, slow=28` | Weighted combo of three buying-pressure ratios. Divergences at extremes are key signals. |
| `apo` | APO | `fast=12, slow=26` | Absolute Price Oscillator EMA difference without signal line. Positive = upward momentum. |
| `mfi` | Money Flow Index | `length=14` | RSI-like but uses price × volume. >80 overbought, <20 oversold. |
| `coppock` | Coppock Curve | `length=10, fast=11, slow=14` | Long-term momentum from rate-of-change. Designed for monthly bottoms; works on any TF. |
| `dpo` | DPO | `length=20` | Detrended Price Oscillator removes trend to expose cycles. Positive = above cycle average. |
| `fisher` | Fisher Transform | `length=9` | Converts price to Gaussian distribution. Sharp spikes at ±2 often signal reversals. |
| `rvgi` | RVGI | `length=14, swma_length=4` | Compares closeopen to highlow range. Signal line crossovers indicate momentum shifts. |
| `kst` | Know Sure Thing | `r1=10,r2=13,r3=15,r4=20,n1=10,n2=13,n3=15,n4=9,signal=9` | Four smoothed ROC values summed. Zero-line and signal-line crossovers are signals. |
### Volatility
| `pandas_ta_name` | Display Name | Key Parameters | Description & Interpretation |
|------------------|--------------|----------------|-------------------------------|
| `atr` | ATR | `length=14` | Average True Range normalized measure of bar-to-bar volatility. Used for stop sizing. |
| `kc` | Keltner Channels | `length=20, scalar=2.0` | EMA ± N × ATR. Price outside channel = trend extension; inside = consolidation. |
| `donchian` | Donchian Channels | `lower_length=20, upper_length=20` | Highest high / lowest low over `length`. Breakout above/below = momentum signal. |
### Volume (plotted in separate pane)
| `pandas_ta_name` | Display Name | Key Parameters | Description & Interpretation |
|------------------|--------------|----------------|-------------------------------|
| `obv` | OBV | *(no params)* | Cumulative volume: added on up days, subtracted on down days. Divergence from price = leading signal. |
| `ad` | A/D Line | *(no params)* | Accumulation/Distribution running total of money flow multiplier × volume. |
| `adosc` | Chaikin Oscillator | `fast=3, slow=10` | EMA difference of A/D line. Positive = accumulation; negative = distribution. |
| `cmf` | Chaikin MF | `length=20` | Sum of money flow volume / total volume. +0.25 strong buy pressure; 0.25 strong sell. |
| `eom` | Ease of Movement | `length=14` | Relates price change to volume. High value = price moved easily on low volume. |
| `efi` | Elder's Force Index | `length=13` | Price change × volume. Positive spikes = strong buying; negative = strong selling. |
| `kvo` | Klinger Oscillator | `fast=34, slow=55, signal=13` | EMA difference of a volume-force measure. Signal-line crossovers are trade signals. |
| `pvt` | PVT | *(no params)* | Cumulative volume × % price change. Similar to OBV but uses % change rather than direction. |
### Statistics / Price Transforms
| `pandas_ta_name` | Display Name | Key Parameters | Description & Interpretation |
|------------------|--------------|----------------|-------------------------------|
| `stdev` | Std Deviation | `length=20` | Standard deviation of close. Rises in volatile periods; used for volatility regimes. |
| `linreg` | Lin Reg | `length=14` | Least-squares regression endpoint over `length` bars. Smooth trend line; not predictive. |
| `slope` | Lin Reg Slope | `length=14` | Gradient of the regression line. Positive = upward trend; magnitude = steepness. |
| `hl2` | HL2 | *(no params)* | `(high + low) / 2`. Simple midpoint of each bar. |
| `hlc3` | HLC3 | *(no params)* | `(high + low + close) / 3`. Typical price, used in many indicator calculations. |
| `ohlc4` | OHLC4 | *(no params)* | `(open + high + low + close) / 4`. Average price per bar. |
### Trend
| `pandas_ta_name` | Display Name | Key Parameters | Description & Interpretation |
|------------------|--------------|----------------|-------------------------------|
| `psar` | Parabolic SAR | `af0=0.02, af=0.02, max_af=0.2` | Trailing stop dots that follow price and flip on reversal. `af` controls acceleration. |
| `vortex` | Vortex | `length=14` | VI+ and VI measure upward vs downward movement. VI+ > VI = uptrend and vice versa. |
| `chop` | Choppiness | `length=14` | 0100: high (>61.8) = choppy/sideways, low (<38.2) = strong trend. Does not give direction. |
---
## Section B — Workspace Format & Tools
### Indicators Store
The `indicators` workspace store has an `indicators` wrapper key containing a JSON object keyed by indicator ID:
```
{
"indicators": {
"ind_1234567890": {
"id": "ind_1234567890", // unique ID, use "ind_" + Date.now()
"pandas_ta_name": "rsi", // lowercase pandas-ta function name from Section A
"instance_name": "rsi_1234567890", // id without "ind_" prefix
"parameters": { "length": 14 }, // pandas-ta keyword args
"visible": true,
"pane": "chart", // "chart" = price pane; "indicator_pane_1" etc for separate
"symbol": "BTC/USDT.BINANCE", // optional, current chart symbol
"created_at": 1712345678, // optional unix timestamp
"modified_at": 1712345678 // optional unix timestamp
// These fields are managed by the web client — do NOT set them:
// "tv_study_id", "tv_indicator_name", "tv_inputs"
},
...
}
}
```
**Important**: All patch paths must start with `/indicators/`. The indicator objects live under the `indicators` key, not at the top level of the store.
**Pane values:**
- `"chart"` price pane overlays (MAs, BBands, SuperTrend, Ichimoku, VWAP, etc.)
- `"indicator_pane_1"`, `"indicator_pane_2"`, etc. separate sub-panes below the chart
**General rule**: Overlap/MA indicators go on `"chart"`. Momentum, Volume, Volatility (ATR, Donchian, Keltner), and Statistics indicators go on `"indicator_pane_N"`. When adding multiple separate-pane indicators, reuse the same pane number if they logically belong together, or use a new number.
### Reading Indicators
```
workspace_read("indicators")
```
Returns the full store object. Always read first before modifying so you know the current state. The indicator objects are under the `indicators` key: `result.data.indicators`.
When asked to list or describe current indicators, include:
- The display name and parameters
- A brief description of what each indicator measures and how to interpret it (from Section A)
- Which pane it's on
### Adding an Indicator
Generate a unique ID as `"ind_" + timestamp` (e.g. `"ind_1712345678123"`).
```
workspace_patch("indicators", [
{
"op": "add",
"path": "/indicators/ind_1712345678123",
"value": {
"id": "ind_1712345678123",
"pandas_ta_name": "rsi",
"instance_name": "rsi_1712345678123",
"parameters": { "length": 14 },
"visible": true,
"pane": "indicator_pane_1",
"created_at": 1712345678
}
}
])
```
### Modifying an Indicator
Read first to get the ID, then patch the specific field:
```
workspace_patch("indicators", [
{ "op": "replace", "path": "/indicators/ind_1712345678123/parameters/length", "value": 21 }
])
```
To modify multiple parameters at once:
```
workspace_patch("indicators", [
{ "op": "replace", "path": "/indicators/ind_1712345678123/parameters", "value": { "fast": 8, "slow": 21, "signal": 9 } }
])
```
### Removing an Indicator
```
workspace_patch("indicators", [
{ "op": "remove", "path": "/indicators/ind_1712345678123" }
])
```
### Visibility Toggle
```
workspace_patch("indicators", [
{ "op": "replace", "path": "/indicators/ind_1712345678123/visible", "value": false }
])
```
---
## Section C — Custom Indicators
Custom indicators are Python scripts in the `indicator` category. Use `python_write` / `python_edit` / `python_read` / `python_list` exactly as you would for research scripts, but with `category="indicator"`.
`python_write` requires `category`, `name`, `description`, `details`, and `code`. The `details` field must be a complete markdown description of the indicator formula, algorithm, all parameters and their semantics, input series, output columns, and any non-obvious implementation choices with enough detail that another agent could reproduce the code from it alone.
### Writing a Custom Indicator Script
A custom indicator must define a **top-level function whose name is the lowercase, snake_case form of the `name` passed to `python_write`**: take `name`, lowercase it, replace spaces and hyphens with underscores. For example, `name="TrendFlex"` function `def trendflex(...)`, `name="VW RSI"` function `def vw_rsi(...)`.
The function receives the OHLC columns it needs as positional arguments, matching `input_series` in the metadata. It must return a `pd.Series` (single output) or `pd.DataFrame` (multi-output, column names must match `output_columns`).
```python
# Example: volume-weighted RSI (function name = "vw_rsi", directory name = "vw_rsi")
import pandas as pd
import pandas_ta as ta
def vw_rsi(close: pd.Series, volume: pd.Series, length: int = 14) -> pd.Series:
"""Volume-weighted RSI: RSI scaled by relative volume."""
rsi = ta.rsi(close, length=length)
vol_weight = volume / volume.rolling(length).mean()
return (rsi * vol_weight).rolling(3).mean()
```
For multi-output (e.g. bands-style), return a `pd.DataFrame` with columns matching `output_columns`:
```python
import pandas as pd
import pandas_ta as ta
def vol_bands(close: pd.Series, volume: pd.Series, length: int = 20) -> pd.DataFrame:
"""Volatility bands based on volume-weighted std."""
mid = close.rolling(length).mean()
std = (close * (volume / volume.rolling(length).mean())).rolling(length).std()
return pd.DataFrame({"upper": mid + 2 * std, "mid": mid, "lower": mid - 2 * std})
```
After writing a custom indicator with `python_write`, add it to the workspace using `pandas_ta_name: "custom_<sanitized_name>"`.
### Metadata for Custom Indicators
When writing a custom indicator you **must** supply complete metadata so the web client can auto-construct the TradingView plotter. Pass these fields in the `metadata` argument to `python_write`:
**Top-level required fields** (not inside `metadata`):
| Field | Required | Description |
|---|---|---|
| `description` | yes | One-sentence summary |
| `details` | yes | Full markdown description formula, algorithm, all parameters and their semantics, input series, output columns, and any non-obvious choices. Enough detail for another agent to reproduce the code. |
**`metadata` fields:**
| Field | Type | Required | Description |
|---|---|---|---|
| `parameters` | dict | yes | Parameter schema: `{param_name: {type, default, description?, min?, max?}}` |
| `input_series` | list[str] | yes | OHLCV columns passed to the function in order. Valid: `open`, `high`, `low`, `close`, `volume` |
| `output_columns` | list[dict] | yes | Per-series descriptors see table below |
| `pane` | str | yes | `"price"` (overlaid on candles) or `"separate"` (sub-pane) |
| `filled_areas` | list[dict] | no | Shaded fills between two series see below |
| `bands` | list[dict] | no | Horizontal reference lines (constant-value series recommended instead see note) |
#### `output_columns` format
Each entry describes one output series:
```python
{
"name": "value", # column name returned by the function (or "value" for Series)
"display_name": "My Ind", # optional label shown in TV legend
"description": "...", # optional
"plot": { # optional — omit for default (line, auto-color, width 2)
"style": 0, # LineStudyPlotStyle integer (see table below)
"color": "#2196F3", # CSS hex; omit for auto-assigned color
"linewidth": 2, # 14, default 2
"visible": True # default True
}
}
```
**`plot.style` values (LineStudyPlotStyle):**
| Value | Renders as |
|---|---|
| `0` | Line (default) |
| `1` | Histogram bars |
| `3` | Dots / Cross markers |
| `4` | Area (filled under line) |
| `5` | Columns (vertical bars) |
| `6` | Circles |
| `9` | Step line |
#### `filled_areas` format (optional)
Shaded fills between two series. The web client supports up to 4 fills, paired by index to output column pairs `(0,1)`, `(2,3)`, `(4,5)`, `(6,7)`. For a fill to work, the two series it shades must be at consecutive even/odd positions in `output_columns`.
```python
[
{
"id": "fill_upper_lower", # descriptive id (informational only)
"type": "plot_plot", # always "plot_plot" for fills between series
"series1": "upper", # output_column name of the first boundary
"series2": "lower", # output_column name of the second boundary
"color": "#2196F3", # CSS hex fill color (default: auto)
"opacity": 0.1 # 0.01.0 (default 0.1)
}
]
```
**Note on horizontal reference lines (`bands`):** TradingView's native band mechanism fixes the level value at registration time and cannot be changed per-instance. Instead, add a constant-value output column to your function and mark it with a dashed style:
```python
# In your indicator function:
result["ob"] = 70.0 # constant overbought level
result["os"] = 30.0 # constant oversold level
```
```python
# In output_columns metadata:
{"name": "ob", "display_name": "OB", "plot": {"style": 0, "color": "#ef5350", "linewidth": 1}},
{"name": "os", "display_name": "OS", "plot": {"style": 0, "color": "#26a69a", "linewidth": 1}},
```
#### Complete examples
**Single oscillator line (volume-weighted RSI):**
```python
python_write(
category="indicator",
name="vw_rsi",
description="RSI weighted by relative volume.",
details="""## Volume-Weighted RSI
Computes RSI(length) on close prices, then scales it by relative volume (current volume divided by its rolling mean over the same period), and applies a 3-bar smoothing average.
**Formula:** `(rsi * (volume / volume.rolling(length).mean())).rolling(3).mean()`
**Inputs:** close (Series), volume (Series)
**Output:** single Series named "value" — the smoothed volume-weighted RSI, plotted in a separate pane.
**Parameters:** length (int, default 14, range 2200) — lookback period for both RSI and the volume mean.""",
code="""
import pandas as pd
import pandas_ta as ta
def vw_rsi(close, volume, length=14):
rsi = ta.rsi(close, length=length)
vol_weight = volume / volume.rolling(length).mean()
return (rsi * vol_weight).rolling(3).mean()
""",
metadata={
"parameters": {
"length": {"type": "int", "default": 14, "min": 2, "max": 200, "description": "RSI period"}
},
"input_series": ["close", "volume"],
"output_columns": [
{"name": "value", "display_name": "VW-RSI", "plot": {"style": 0}}
],
"pane": "separate"
}
)
```
**Bollinger Bands with fill (upper + mid + lower, shaded between upper and lower):**
```python
python_write(
category="indicator",
name="my_bbands",
description="Custom Bollinger Bands.",
details="""## Custom Bollinger Bands
Standard Bollinger Bands computed via pandas-ta on close prices.
**Formula:** upper = SMA(length) + std * σ(length); lower = SMA(length) - std * σ(length); mid = SMA(length)
**Inputs:** close (Series)
**Outputs:** upper, mid, lower — three Series plotted on the price pane with a shaded fill between upper and lower.
**Parameters:** length (int, default 20, range 5500), std (float, default 2.0, range 0.55.0)""",
code="""
import pandas as pd
import pandas_ta as ta
def my_bbands(close, length=20, std=2.0):
bb = ta.bbands(close, length=length, std=std)
return pd.DataFrame({
"upper": bb.iloc[:, 0],
"mid": bb.iloc[:, 1],
"lower": bb.iloc[:, 2],
})
""",
metadata={
"parameters": {
"length": {"type": "int", "default": 20, "min": 5, "max": 500},
"std": {"type": "float", "default": 2.0, "min": 0.5, "max": 5.0}
},
"input_series": ["close"],
"output_columns": [
{"name": "upper", "display_name": "Upper", "plot": {"style": 0, "color": "#2196F3"}},
{"name": "lower", "display_name": "Lower", "plot": {"style": 0, "color": "#2196F3"}},
{"name": "mid", "display_name": "Mid", "plot": {"style": 0, "color": "#FF9800"}}
],
"pane": "price",
"filled_areas": [
{"id": "fill", "type": "plot_plot", "series1": "upper", "series2": "lower",
"color": "#2196F3", "opacity": 0.08}
]
}
)
```
Note: `upper` and `lower` are at positions 0 and 1 in `output_columns`, which maps to fill slot `fill_0` (the only fill slot pairing positions 0 and 1).
**MACD-style (line + signal + histogram):**
```python
"output_columns": [
{"name": "macd", "display_name": "MACD", "plot": {"style": 0, "color": "#2196F3"}},
{"name": "signal", "display_name": "Signal", "plot": {"style": 0, "color": "#FF9800"}},
{"name": "hist", "display_name": "Hist", "plot": {"style": 1, "color": "#4CAF50"}}
],
"pane": "separate"
```
### Adding a Custom Indicator to the Workspace
After writing and validating, patch the workspace with **both** the standard fields and `custom_metadata` (the web client uses this to build the TradingView custom study):
```
workspace_patch("indicators", [
{
"op": "add",
"path": "/indicators/ind_1712345678123",
"value": {
"id": "ind_1712345678123",
"pandas_ta_name": "custom_vw_rsi",
"instance_name": "custom_vw_rsi_1712345678123",
"parameters": { "length": 14 },
"visible": true,
"pane": "indicator_pane_1",
"created_at": 1712345678,
"custom_metadata": {
"display_name": "Volume-Weighted RSI",
"parameters": {
"length": {"type": "int", "default": 14, "min": 2, "max": 200, "description": "RSI period"}
},
"input_series": ["close", "volume"],
"output_columns": [
{"name": "value", "display_name": "VW-RSI", "plot": {"style": 0}}
],
"pane": "separate"
}
}
}
])
```
The `custom_metadata` block must match what was stored in the indicator's `metadata.json`.
### Validating with evaluate_indicator
Use `evaluate_indicator` to test any indicator (standard or custom) before adding it to the workspace. This confirms it computes correctly on real data:
```
evaluate_indicator(
symbol="BTC/USDT.BINANCE",
from_time="30 days ago",
to_time="0 minutes ago",
period_seconds=3600,
pandas_ta_name="custom_vw_rsi",
parameters={"length": 14}
)
```
**Time format for `from_time`/`to_time`**: Use a relative string like `"30 days ago"` / `"1 minute ago"` (format: `"N unit(s) ago"` where unit is second/minute/hour/day/week/month/year), an ISO date string like `"2024-04-20"`, or a Unix timestamp integer. Do **not** use `"now"` it is not a valid value; use `"0 minutes ago"` instead.
Returns a structured array of `{timestamp, value}` (or multiple value columns for multi-output indicators like MACD, BBands). Use the results to confirm the indicator is computing as expected before patching the workspace.
---
## Workflow
1. **Read first**: Always call `workspace_read("indicators")` before any modification so you know what's already on the chart.
2. **Check before creating custom indicators**: Before writing a new custom indicator with `python_write`, call `python_list(category="indicator")` to see what already exists. If an indicator with the same name (or a matching sanitized name) is already present, reuse or update it rather than creating a duplicate. Two indicator directories with different capitalizations (e.g. `TrendFlex` and `trendflex`) map to the same `pandas_ta_name` (`custom_trendflex`) and will conflict.
3. **List descriptively**: When asked what indicators are showing, include the brief description and interpretation from Section A for each not just the name and parameters.
4. **Validate custom indicators**: Use `evaluate_indicator` after writing a custom indicator script to confirm it runs without errors before adding to workspace.
5. **Patch, don't overwrite**: Always use `workspace_patch` never call `workspace_write` on the indicators store, as that would replace all indicators including ones the user added manually via the UI.
6. **Confirm changes**: After patching, briefly confirm what was added/changed/removed and what the indicator does (one sentence from Section A).
7. **Pane assignment**: When adding indicators, assign the correct pane type. When adding multiple momentum indicators, stack them in separate panes (`indicator_pane_1`, `indicator_pane_2`, etc.) unless the user asks otherwise.

View File

@@ -1,2 +0,0 @@
# Auto-generated at build time by bin/build
api-source/

View File

@@ -1,32 +0,0 @@
# Research Subagent Configuration
name: research
description: Creates and runs Python research scripts for market analysis, charting, and statistical analysis
# Model configuration
model: claude-sonnet-4-6
temperature: 0.3
maxTokens: 8192
# Memory files to load from memory/ directory
memoryFiles:
- api-reference.md
- usage-examples.md
- pandas-ta-reference.md
# System prompt file
systemPromptFile: system-prompt.md
# Capabilities this subagent provides
capabilities:
- research_scripting
- data_analysis
- charting
- statistical_analysis
# Tools available to this subagent
tools:
platform: [] # No platform tools needed (works at script level)
mcp:
- category_* # All category_ tools (write, edit, read, list)
- execute_research # Script execution tool

View File

@@ -1,255 +0,0 @@
import { BaseSubagent, type SubagentConfig, type SubagentContext } from '../base-subagent.js';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { SystemMessage } from '@langchain/core/messages';
import type { BaseMessage } from '@langchain/core/messages';
import type { FastifyBaseLogger } from 'fastify';
import type { MCPClientConnector } from '../../mcp-client.js';
import type { HarnessEvent } from '../../harness-events.js';
/**
* Result from research subagent execution
*/
export interface ResearchResult {
text: string;
images: Array<{
data: string;
mimeType: string;
}>;
}
/**
* Research Subagent
*
* Specialized agent for creating and running Python research scripts.
* Uses python_* MCP tools to:
* - Create/edit research scripts with DataAPI and ChartingAPI
* - Execute scripts and capture matplotlib charts
* - Iterate on errors with autonomous coding loop
*
* The subagent has direct access to MCP tools and handles the full
* coding loop without requiring skill-level orchestration.
*
* Images from script execution are extracted and returned separately
* but are NOT loaded into the LLM context (pass-through only).
*/
export class ResearchSubagent extends BaseSubagent {
private lastImages: Array<{data: string; mimeType: string}> = [];
// Shared with the MCP tool wrappers — populated as tools run, cleared per execution
private imageCapture: Array<{data: string; mimeType: string}> = [];
constructor(
config: SubagentConfig,
model: BaseChatModel,
logger: FastifyBaseLogger,
mcpClient?: MCPClientConnector,
tools?: any[]
) {
super(config, model, logger, mcpClient, tools);
}
protected getRecursionLimit() { return 40; }
protected getFallbackText() { return 'Research completed.'; }
setImageCapture(capture: Array<{data: string; mimeType: string}>): void {
this.imageCapture = capture;
}
/**
* Fetch custom indicators from the sandbox and return a formatted system prompt section.
* Returns empty string if there are no custom indicators or the call fails.
*/
private async fetchCustomIndicatorsSection(): Promise<string> {
try {
const raw = await this.callMCPTool('python_list', { category: 'indicator' });
const r = raw as any;
const text = r?.content?.[0]?.text ?? r?.[0]?.text;
const parsed = typeof text === 'string' ? JSON.parse(text) : raw;
const items: any[] = parsed?.items ?? [];
if (items.length === 0) return '';
const lines: string[] = ['\n\n## Custom Indicators\n'];
lines.push('The user has defined the following custom indicators. Use `ta.custom_<name>` where `<name>` is the lowercase sanitized function name shown below.\n');
for (const item of items) {
const displayName: string = item.name ?? 'unknown';
const description: string = item.description ?? '';
const meta: any = item.metadata ?? {};
// Derive the ta attribute name: sanitize display name to lowercase + underscores
const taAttr = `custom_${displayName.toLowerCase().replace(/[^\w]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '')}`;
const inputSeries: string[] = meta.input_series ?? ['close'];
const params: Record<string, any> = meta.parameters ?? {};
const pane: string = meta.pane ?? 'separate';
const inputStr = inputSeries.map((s: string) => `df['${s}']`).join(', ');
const paramStr = Object.entries(params)
.map(([k, v]: [string, any]) => `${k}=${JSON.stringify(v?.default ?? null)}`)
.join(', ');
const callExample = paramStr
? `ta.${taAttr}(${inputStr}, ${paramStr})`
: `ta.${taAttr}(${inputStr})`;
const outputNames = (meta.output_columns ?? [{ name: 'value' }])
.map((c: any) => c.name)
.join(', ');
lines.push(`### ${displayName}`);
if (description) lines.push(description);
lines.push(`- **Call**: \`${callExample}\``);
lines.push(`- **Outputs**: ${outputNames} | **Pane**: ${pane}`);
lines.push('');
}
return lines.join('\n');
} catch (err) {
this.logger.warn({ err }, 'Failed to fetch custom indicators for prompt injection');
return '';
}
}
/**
* Augment system message with custom indicators section.
*/
protected async buildSystemMessage(
context: SubagentContext,
instruction: string
): Promise<{ systemMessage: SystemMessage; humanMessage: BaseMessage }> {
const { systemMessage, humanMessage } = await super.buildSystemMessage(context, instruction);
const customIndicatorsSection = await this.fetchCustomIndicatorsSection();
if (customIndicatorsSection) {
const base = typeof systemMessage.content === 'string'
? systemMessage.content
: JSON.stringify(systemMessage.content);
return { systemMessage: new SystemMessage(base + customIndicatorsSection), humanMessage };
}
return { systemMessage, humanMessage };
}
/**
* Execute research request using LangGraph's createReactAgent.
* Wraps executeAgent to manage image capture state.
*/
async execute(context: SubagentContext, instruction: string): Promise<string> {
// Clear previous images (in-place so tool wrappers keep the same array reference)
this.imageCapture.length = 0;
this.lastImages = [];
const finalText = await this.executeAgent(context, instruction);
// Images were captured in real-time by the MCP tool wrappers into this.imageCapture
this.lastImages = [...this.imageCapture];
this.logger.info(
{ textLength: finalText.length, imageCount: this.lastImages.length },
'Research subagent finished'
);
return finalText;
}
/**
* Execute with full result including images
* This is the method that ResearchSkill should use
*/
async executeWithImages(context: SubagentContext, instruction: string): Promise<ResearchResult> {
const text = await this.execute(context, instruction);
return {
text,
images: this.lastImages,
};
}
/**
* Get images from last execution
*/
getLastImages(): Array<{data: string; mimeType: string}> {
return this.lastImages;
}
/**
* Stream typed HarnessEvents using LangGraph's agent.stream().
* Emits subagent_tool_call when tools fire, subagent_chunk for the final AI response.
* Returns the final text string as the generator return value.
*/
async *streamEvents(context: SubagentContext, instruction: string, signal?: AbortSignal): AsyncGenerator<HarnessEvent, string> {
this.logger.info({ subagent: this.getName() }, 'streamEvents starting');
if (!this.hasMCPClient()) {
throw new Error('MCP client not available for research subagent');
}
this.imageCapture.length = 0;
this.lastImages = [];
// Emit immediately so the UI shows the subagent has started — LLM generation
// can take minutes with non-streaming models and nothing else reaches the UI until
// the first `updates` event fires (after the LLM finishes its first response).
yield { type: 'subagent_tool_call', agentName: this.config.name, toolName: 'Thinking...', label: 'Thinking...' };
const finalText = yield* this.streamEventsCore(context, instruction, signal);
this.lastImages = [...this.imageCapture];
if (!finalText) {
this.logger.warn(
{ imageCount: this.lastImages.length },
'Research subagent: model returned empty output'
);
} else {
this.logger.info(
{ textLength: finalText.length, imageCount: this.lastImages.length },
'streamEvents finished'
);
}
return finalText;
}
/**
* Stream research execution (raw model streaming, no agent loop)
*/
async *stream(context: SubagentContext, instruction: string): AsyncGenerator<string> {
this.logger.info(
{
subagent: this.getName(),
userId: context.userContext.userId,
},
'Streaming research request'
);
if (!this.hasMCPClient()) {
throw new Error('MCP client not available for research subagent');
}
// Clear previous images
this.lastImages = [];
const messages = this.buildMessages(context, instruction);
const stream = await this.model.stream(messages);
for await (const chunk of stream) {
if (typeof chunk.content === 'string') {
yield chunk.content;
}
}
}
}
/**
* Factory function to create and initialize ResearchSubagent
*/
export async function createResearchSubagent(
model: BaseChatModel,
logger: FastifyBaseLogger,
basePath: string,
mcpClient?: MCPClientConnector,
tools?: any[],
imageCapture?: Array<{data: string; mimeType: string}>
): Promise<ResearchSubagent> {
const config = await BaseSubagent.loadConfig(basePath);
const subagent = new ResearchSubagent(config, model, logger, mcpClient, tools);
if (imageCapture !== undefined) {
subagent.setImageCapture(imageCapture);
}
await subagent.initialize(basePath);
return subagent;
}

View File

@@ -1,498 +0,0 @@
# Dexorder Research API Reference
This file contains the complete Python API source code with full docstrings.
These files are copied verbatim from `sandbox/dexorder/api/`.
The API provides access to market data and charting capabilities for research scripts.
---
## Overview
Research scripts access the API via:
```python
from dexorder.api import get_api
api = get_api()
```
The API instance provides:
- `api.data` - DataAPI for fetching OHLC market data
- `api.charting` - ChartingAPI for creating financial charts
---
## Complete API Source Code
The following sections contain the verbatim Python source files with complete
type hints, docstrings, and examples.
### api.py
```python
"""
Main Dexorder API - provides access to market data and charting.
"""
import logging
from .charting_api import ChartingAPI
from .data_api import DataAPI
log = logging.getLogger(__name__)
class API:
"""
Main API for accessing market data and creating charts.
This is the primary interface for research scripts and trading strategies.
Access this via get_api() in research scripts.
Attributes:
data: DataAPI for fetching historical and current market data
charting: ChartingAPI for creating candlestick charts and visualizations
Example:
from dexorder.api import get_api
import asyncio
api = get_api()
# Fetch data
df = asyncio.run(api.data.historical_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600,
start_time="2021-12-20",
end_time="2021-12-21"
))
# Create chart
fig, ax = api.charting.plot_ohlc(df, title="BTC/USDT 1H")
"""
def __init__(self, charting: ChartingAPI, data: DataAPI):
self.charting: ChartingAPI = charting
self.data: DataAPI = data
```
### data_api.py
```python
from abc import ABC, abstractmethod
from typing import Optional, List
import pandas as pd
from dexorder.utils import TimestampInput
class DataAPI(ABC):
"""
API for accessing market data.
Provides methods to query OHLC (Open, High, Low, Close) candlestick data
for cryptocurrency markets.
"""
@abstractmethod
async def historical_ohlc(
self,
ticker: str,
period_seconds: int,
start_time: TimestampInput,
end_time: TimestampInput,
extra_columns: Optional[List[str]] = None,
) -> pd.DataFrame:
"""
Fetch historical OHLC candlestick data for a market.
Args:
ticker: Market identifier in format "MARKET.EXCHANGE"
Examples: "BTC/USDT.BINANCE", "ETH/USD.COINBASE"
period_seconds: Candle period in seconds
Common values:
- 60 (1 minute)
- 300 (5 minutes)
- 900 (15 minutes)
- 3600 (1 hour)
- 86400 (1 day)
- 604800 (1 week)
start_time: Start of time range. Accepts:
- Unix timestamp in seconds (int/float): 1640000000
- Date string: "2021-12-20" or "2021-12-20 12:00:00"
- datetime object: datetime(2021, 12, 20)
- pandas Timestamp: pd.Timestamp("2021-12-20")
end_time: End of time range. Same formats as start_time.
extra_columns: Optional additional columns to include beyond the standard
OHLC columns. Available options:
- "volume" - Total volume (decimal float)
- "buy_vol" - Buy-side volume (decimal float)
- "sell_vol" - Sell-side volume (decimal float)
- "open_time", "high_time", "low_time", "close_time" (timestamps)
- "open_interest" (for futures markets)
- "ticker", "period_seconds"
Returns:
DataFrame with candlestick data sorted by timestamp (ascending).
Standard columns (always included):
- timestamp: Period start time in nanoseconds
- open: Opening price (decimal float)
- high: Highest price (decimal float)
- low: Lowest price (decimal float)
- close: Closing price (decimal float)
Plus any columns specified in extra_columns.
All prices and volumes are automatically converted to decimal floats
using market metadata. No manual conversion is needed.
Returns empty DataFrame if no data is available.
Examples:
# Basic OHLC with Unix timestamp
df = await api.historical_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600,
start_time=1640000000,
end_time=1640086400
)
# Using date strings with volume
df = await api.historical_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600,
start_time="2021-12-20",
end_time="2021-12-21",
extra_columns=["volume"]
)
# Using datetime objects
from datetime import datetime
df = await api.historical_ohlc(
ticker="ETH/USD.COINBASE",
period_seconds=300,
start_time=datetime(2021, 12, 20, 9, 30),
end_time=datetime(2021, 12, 20, 16, 30),
extra_columns=["volume", "buy_vol", "sell_vol"]
)
"""
pass
@abstractmethod
async def latest_ohlc(
self,
ticker: str,
period_seconds: int,
length: int = 1,
extra_columns: Optional[List[str]] = None,
) -> pd.DataFrame:
"""
Query the most recent OHLC candles for a ticker.
This method fetches the latest N completed candles without needing to
specify exact timestamps. Useful for real-time analysis and indicators.
Args:
ticker: Market identifier in format "MARKET.EXCHANGE"
Examples: "BTC/USDT.BINANCE", "ETH/USD.COINBASE"
period_seconds: OHLC candle period in seconds
Common values: 60 (1m), 300 (5m), 900 (15m), 3600 (1h),
86400 (1d), 604800 (1w)
length: Number of most recent candles to return (default: 1)
extra_columns: Optional list of additional column names to include.
Same column options as historical_ohlc:
- "volume", "buy_vol", "sell_vol"
- "open_time", "high_time", "low_time", "close_time"
- "open_interest", "ticker", "period_seconds"
Returns:
Pandas DataFrame with the same column structure as historical_ohlc,
containing the N most recent completed candles sorted by timestamp.
Returns empty DataFrame if no data is available.
Examples:
# Get the last candle
df = await api.latest_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600
)
# Returns: timestamp, open, high, low, close
# Get the last 50 5-minute candles with volume
df = await api.latest_ohlc(
ticker="ETH/USD.COINBASE",
period_seconds=300,
length=50,
extra_columns=["volume", "buy_vol", "sell_vol"]
)
# Get recent candles with all timing data
df = await api.latest_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=60,
length=100,
extra_columns=["open_time", "high_time", "low_time", "close_time"]
)
Note:
This method returns only completed candles. The current (incomplete)
candle is not included.
"""
pass
```
### charting_api.py
```python
import logging
from abc import abstractmethod, ABC
from typing import Optional, Tuple, List
import pandas as pd
from matplotlib import pyplot as plt
from matplotlib.figure import Figure
class ChartingAPI(ABC):
"""
API for creating financial charts and visualizations.
Provides methods to create candlestick charts, add technical indicator panels,
and build custom visualizations. All figures are automatically captured and
returned to the client as images.
Basic workflow:
1. Create a chart with plot_ohlc() → returns Figure and Axes
2. Optionally overlay indicators on the main axes (e.g., moving averages)
3. Optionally add indicator panels below with add_indicator_panel()
4. Figures are automatically captured (no need to save manually)
"""
@abstractmethod
def plot_ohlc(
self,
df: pd.DataFrame,
title: Optional[str] = None,
volume: bool = False,
style: str = "charles",
figsize: Tuple[int, int] = (12, 8),
**kwargs
) -> Tuple[Figure, plt.Axes]:
"""
Create a candlestick chart from OHLC data.
Args:
df: DataFrame with OHLC data. Required columns: open, high, low, close.
Column names are case-insensitive.
title: Chart title (optional)
volume: If True, shows volume bars below the candlesticks (requires 'volume' column)
style: Visual style for the chart. Available styles:
"charles" (default), "binance", "blueskies", "brasil", "checkers",
"classic", "mike", "nightclouds", "sas", "starsandstripes", "yahoo"
figsize: Figure size as (width, height) in inches. Default: (12, 8)
**kwargs: Additional styling arguments
Returns:
Tuple of (Figure, Axes):
- Figure: matplotlib Figure object
- Axes: Main candlestick axes (use for overlaying indicators)
Examples:
# Basic chart
fig, ax = api.plot_ohlc(df)
# With volume and title
fig, ax = api.plot_ohlc(
df,
title="BTC/USDT 1H",
volume=True,
style="binance"
)
# Overlay moving average
# NOTE: mplfinance uses integer x-positions (0..N-1) internally,
# so overlays must use range(len(df)), not df.index.
fig, ax = api.plot_ohlc(df)
ax.plot(range(len(df)), df['sma_20'], label="SMA 20", color="blue")
ax.legend()
"""
pass
@abstractmethod
def add_indicator_panel(
self,
fig: Figure,
df: pd.DataFrame,
columns: Optional[List[str]] = None,
ylabel: Optional[str] = None,
height_ratio: float = 0.3,
ylim: Optional[Tuple[float, float]] = None,
**kwargs
) -> plt.Axes:
"""
Add an indicator panel below the chart with time-aligned x-axis.
Use this to display indicators that should be shown separately from the
price chart (e.g., RSI, MACD, volume).
Args:
fig: Figure object from plot_ohlc()
df: DataFrame with indicator data (must have same index as OHLC data)
columns: Column names to plot. If None, plots all numeric columns.
ylabel: Y-axis label (e.g., "RSI", "MACD")
height_ratio: Panel height relative to main chart (default: 0.3 = 30%)
ylim: Y-axis limits as (min, max). If None, auto-scales.
**kwargs: Line styling options (color, linewidth, linestyle, alpha)
Returns:
Axes object for the new panel (use for further customization)
Examples:
# Add RSI panel with reference lines
fig, ax = api.plot_ohlc(df)
rsi_ax = api.add_indicator_panel(
fig, df,
columns=["rsi"],
ylabel="RSI",
ylim=(0, 100)
)
rsi_ax.axhline(30, color='green', linestyle='--', alpha=0.5)
rsi_ax.axhline(70, color='red', linestyle='--', alpha=0.5)
# Add MACD panel
fig, ax = api.plot_ohlc(df)
api.add_indicator_panel(
fig, df,
columns=["macd", "macd_signal"],
ylabel="MACD"
)
"""
pass
@abstractmethod
def create_figure(
self,
figsize: Tuple[int, int] = (12, 8),
style: str = "charles"
) -> Tuple[Figure, plt.Axes]:
"""
Create a styled figure for custom visualizations.
Use this when you want to create charts other than candlesticks
(e.g., histograms, scatter plots, heatmaps).
Args:
figsize: Figure size as (width, height) in inches. Default: (12, 8)
style: Style name for consistent theming. Default: "charles"
Returns:
Tuple of (Figure, Axes) ready for plotting
Examples:
# Histogram
fig, ax = api.create_figure()
ax.hist(returns, bins=50)
ax.set_title("Return Distribution")
# Heatmap
fig, ax = api.create_figure(figsize=(10, 10))
import seaborn as sns
sns.heatmap(correlation_matrix, ax=ax)
ax.set_title("Correlation Matrix")
"""
pass
```
### __init__.py
```python
"""
Dexorder API - market data and charting for research and trading.
For research scripts, import and use get_api() to access the API:
from dexorder.api import get_api
import asyncio
api = get_api()
df = asyncio.run(api.data.historical_ohlc(...))
fig, ax = api.charting.plot_ohlc(df)
"""
import logging
import threading
from typing import Optional
from dexorder.api.api import API
from dexorder.api.charting_api import ChartingAPI
from dexorder.api.data_api import DataAPI
log = logging.getLogger(__name__)
# Global API instance - managed by main.py
_global_api: Optional[API] = None
# Thread-local API — used by harness threads so they don't overwrite the global
_thread_local = threading.local()
def get_api() -> API:
"""
Get the API instance for accessing market data and charts.
Use this in research scripts to access the data and charting APIs.
Returns:
API instance with data and charting capabilities
Raises:
RuntimeError: If called before API initialization (should not happen in research scripts)
Example:
from dexorder.api import get_api
import asyncio
api = get_api()
# Fetch data
df = asyncio.run(api.data.historical_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600,
start_time="2021-12-20",
end_time="2021-12-21"
))
# Create chart
fig, ax = api.charting.plot_ohlc(df, title="BTC/USDT")
"""
# Thread-local takes priority (set by harness threads)
api = getattr(_thread_local, 'api', None)
if api is not None:
return api
if _global_api is None:
raise RuntimeError("API not initialized")
return _global_api
def set_api(api: API) -> None:
"""Set the API instance.
When called from the main thread, sets the global API used by all threads.
When called from a non-main thread (e.g. harness threads), sets a thread-local
API so the global is not overwritten.
"""
if threading.current_thread() is threading.main_thread():
global _global_api
_global_api = api
else:
_thread_local.api = api
__all__ = ['API', 'ChartingAPI', 'DataAPI', 'get_api', 'set_api']
```
---
For practical usage patterns and complete working examples, see `usage-examples.md`.

View File

@@ -1,227 +0,0 @@
# pandas-ta Reference for Research Scripts
The sandbox environment uses **pandas-ta** as the standard indicator library. Always use it for technical indicator calculations; do not write manual rolling/ewm implementations.
```python
import pandas_ta as ta
```
## Calling Convention
pandas-ta functions accept a Series (or OHLCV columns) plus keyword parameters that match pandas-ta's documented argument names:
```python
# Single-series indicator
rsi = ta.rsi(df['close'], length=14) # returns Series
# OHLCV indicator
atr = ta.atr(df['high'], df['low'], df['close'], length=14)
# Multi-output indicator (returns DataFrame)
macd_df = ta.macd(df['close'], fast=12, slow=26, signal=9)
# columns: MACD_12_26_9, MACDh_12_26_9, MACDs_12_26_9
bbands_df = ta.bbands(df['close'], length=20, std=2.0)
# columns: BBL_20_2.0, BBM_20_2.0, BBU_20_2.0, BBB_20_2.0, BBP_20_2.0
```
## Default Parameters
Key defaults to keep in mind:
- Most period/length indicators: `length=14` (use `length=` not `timeperiod=`)
- `bbands`: `length=20, std=2.0` (note: single `std`, not separate upper/lower)
- `macd`: `fast=12, slow=26, signal=9`
- `stoch`: `k=14, d=3, smooth_k=3`
- `psar`: `af0=0.02, af=0.02, max_af=0.2`
- `vwap`: `anchor='D'` (requires DatetimeIndex)
- `ichimoku`: `tenkan=9, kijun=26, senkou=52`
## Available Indicators
These match the indicators supported by the TradingView web client. Use the pandas-ta function name shown here (lowercase):
### Overlap / Moving Averages — plotted on the price pane
| Function | Description |
|----------|-------------|
| `sma` | Simple Moving Average — plain arithmetic mean over `length` periods |
| `ema` | Exponential Moving Average — more weight on recent prices |
| `wma` | Weighted Moving Average — linearly increasing weights |
| `dema` | Double EMA — two layers of EMA to reduce lag |
| `tema` | Triple EMA — three layers of EMA, even less lag than DEMA |
| `trima` | Triangular MA — double-smoothed SMA, very smooth |
| `kama` | Kaufman Adaptive MA — adapts speed to market noise/trending conditions |
| `t3` | T3 Moving Average — Tillson's smooth, low-lag MA using six EMAs |
| `hma` | Hull MA — very low-lag MA using WMAs |
| `alma` | Arnaud Legoux MA — Gaussian-weighted MA with reduced lag and noise |
| `midpoint` | Midpoint of close over `length` periods: (highest + lowest) / 2 |
| `midprice` | Midpoint of high/low over `length` periods |
| `supertrend` | Trend-following band (ATR-based) that flips above/below price |
| `ichimoku` | Ichimoku Cloud — multi-line Japanese trend/support/resistance system |
| `vwap` | Volume-Weighted Average Price — average price weighted by volume, resets on `anchor` |
| `vwma` | Volume-Weighted MA — like SMA but candles weighted by volume |
| `bbands` | Bollinger Bands — SMA ± N standard deviations; returns upper, mid, lower bands |
### Momentum — typically plotted in a separate pane
| Function | Description |
|----------|-------------|
| `rsi` | Relative Strength Index — 0100 oscillator measuring speed of price changes |
| `macd` | MACD — difference of two EMAs plus signal line and histogram |
| `stoch` | Stochastic Oscillator — %K/%D, measures close vs recent high/low range |
| `stochrsi` | Stochastic RSI — applies stochastic formula to RSI values |
| `cci` | Commodity Channel Index — deviation of price from its statistical mean |
| `willr` | Williams %R — inverse stochastic, 100 to 0 oscillator |
| `mom` | Momentum — raw price change over `length` periods |
| `roc` | Rate of Change — percentage price change over `length` periods |
| `trix` | TRIX — 1-period % change of a triple-smoothed EMA |
| `cmo` | Chande Momentum Oscillator — ratio of up/down momentum, 100 to 100 |
| `adx` | Average Directional Index — strength of trend (0100, direction-agnostic) |
| `aroon` | Aroon — measures how recently the highest/lowest price occurred; returns Up, Down, Oscillator |
| `ao` | Awesome Oscillator — difference of 5- and 34-period simple MAs of midprice |
| `bop` | Balance of Power — measures buying vs selling pressure: (closeopen)/(highlow) |
| `uo` | Ultimate Oscillator — weighted combo of three period (fast/medium/slow) buying pressure ratios |
| `apo` | Absolute Price Oscillator — difference between two EMAs (like MACD without signal line) |
| `mfi` | Money Flow Index — RSI-like oscillator using price × volume |
| `coppock` | Coppock Curve — long-term momentum oscillator based on rate-of-change |
| `dpo` | Detrended Price Oscillator — removes trend to show cycle oscillations |
| `fisher` | Fisher Transform — converts price into a Gaussian normal distribution |
| `rvgi` | Relative Vigor Index — compares closeopen to highlow to measure trend vigor |
| `kst` | Know Sure Thing — momentum oscillator from four ROC periods, smoothed |
### Volatility — plotted on price pane or separate
| Function | Description |
|----------|-------------|
| `atr` | Average True Range — average of true range (greatest of HL, HprevC, LprevC) |
| `kc` | Keltner Channels — EMA ± N × ATR bands around price |
| `donchian` | Donchian Channels — highest high / lowest low over `length` periods |
### Volume — plotted in separate pane
| Function | Description |
|----------|-------------|
| `obv` | On Balance Volume — cumulative volume, added on up days, subtracted on down days |
| `ad` | Accumulation/Distribution — running total of the money flow multiplier × volume |
| `adosc` | Chaikin Oscillator — EMA difference of the A/D line |
| `cmf` | Chaikin Money Flow — sum of (money flow volume) / sum of volume over `length` |
| `eom` | Ease of Movement — relates price change to volume; high = price moves easily |
| `efi` | Elder's Force Index — combines price change direction with volume magnitude |
| `kvo` | Klinger Volume Oscillator — EMA difference of volume force |
| `pvt` | Price Volume Trend — cumulative: volume × percentage price change |
### Statistics / Price Transforms
| Function | Description |
|----------|-------------|
| `stdev` | Standard Deviation of close over `length` periods |
| `linreg` | Linear Regression Curve — least-squares line endpoint value over `length` periods |
| `slope` | Linear Regression Slope — gradient of the regression line |
| `hl2` | Median Price — (high + low) / 2 |
| `hlc3` | Typical Price — (high + low + close) / 3 |
| `ohlc4` | Average Price — (open + high + low + close) / 4 |
### Trend
| Function | Description |
|----------|-------------|
| `psar` | Parabolic SAR — trailing stop-and-reverse dots that follow price |
| `vortex` | Vortex Indicator — VI+ / VI lines measuring upward vs downward trend movement |
| `chop` | Choppiness Index — 0100, high = choppy/sideways, low = strong trend |
## Usage Examples
### Single-output indicators
```python
import pandas_ta as ta
df['rsi'] = ta.rsi(df['close'], length=14)
df['ema_20'] = ta.ema(df['close'], length=20)
df['sma_50'] = ta.sma(df['close'], length=50)
df['atr'] = ta.atr(df['high'], df['low'], df['close'], length=14)
df['obv'] = ta.obv(df['close'], df['volume'])
df['adx'] = ta.adx(df['high'], df['low'], df['close'], length=14)['ADX_14']
```
### Multi-output indicators — extract columns by position
```python
# MACD → MACD_12_26_9, MACDh_12_26_9, MACDs_12_26_9
macd_df = ta.macd(df['close'], fast=12, slow=26, signal=9)
df['macd'] = macd_df.iloc[:, 0] # MACD line
df['macd_hist'] = macd_df.iloc[:, 1] # Histogram
df['macd_signal'] = macd_df.iloc[:, 2] # Signal line
# Bollinger Bands → BBL, BBM, BBU, BBB, BBP
bb_df = ta.bbands(df['close'], length=20, std=2.0)
df['bb_lower'] = bb_df.iloc[:, 0] # BBL
df['bb_mid'] = bb_df.iloc[:, 1] # BBM
df['bb_upper'] = bb_df.iloc[:, 2] # BBU
# Stochastic → STOCHk, STOCHd
stoch_df = ta.stoch(df['high'], df['low'], df['close'], k=14, d=3, smooth_k=3)
df['stoch_k'] = stoch_df.iloc[:, 0]
df['stoch_d'] = stoch_df.iloc[:, 1]
# Keltner Channels → KCLe, KCBe, KCUe
kc_df = ta.kc(df['high'], df['low'], df['close'], length=20)
df['kc_lower'] = kc_df.iloc[:, 0]
df['kc_mid'] = kc_df.iloc[:, 1]
df['kc_upper'] = kc_df.iloc[:, 2]
# ADX → ADX_14, DMP_14, DMN_14
adx_df = ta.adx(df['high'], df['low'], df['close'], length=14)
df['adx'] = adx_df.iloc[:, 0] # ADX strength
df['dmp'] = adx_df.iloc[:, 1] # +DI
df['dmn'] = adx_df.iloc[:, 2] # -DI
# Aroon → AROOND_14, AROONU_14, AROONOSC_14
aroon_df = ta.aroon(df['high'], df['low'], length=14)
df['aroon_down'] = aroon_df.iloc[:, 0]
df['aroon_up'] = aroon_df.iloc[:, 1]
# Donchian Channels → DCL, DCM, DCU
dc_df = ta.donchian(df['high'], df['low'], lower_length=20, upper_length=20)
df['dc_lower'] = dc_df.iloc[:, 0]
df['dc_mid'] = dc_df.iloc[:, 1]
df['dc_upper'] = dc_df.iloc[:, 2]
```
### Charting with indicators
```python
import pandas_ta as ta
from dexorder.api import get_api
import asyncio
api = get_api()
df = asyncio.run(api.data.historical_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600,
start_time="2024-01-01",
end_time="2024-01-08",
extra_columns=["volume"]
))
# Compute indicators
df['ema_20'] = ta.ema(df['close'], length=20)
df['rsi'] = ta.rsi(df['close'], length=14)
macd_df = ta.macd(df['close'])
df['macd'] = macd_df.iloc[:, 0]
df['macd_signal'] = macd_df.iloc[:, 2]
# Main price chart with EMA overlay
fig, ax = api.charting.plot_ohlc(df, title="BTC/USDT 1H", volume=True)
ax.plot(range(len(df)), df['ema_20'], label="EMA 20", color="orange", linewidth=1.5) # range(len(df)), not df.index
ax.legend()
# RSI panel
rsi_ax = api.charting.add_indicator_panel(fig, df, columns=["rsi"], ylabel="RSI", ylim=(0, 100))
rsi_ax.axhline(70, color='red', linestyle='--', alpha=0.5)
rsi_ax.axhline(30, color='green', linestyle='--', alpha=0.5)
# MACD panel
api.charting.add_indicator_panel(fig, df, columns=["macd", "macd_signal"], ylabel="MACD")
```

View File

@@ -1,262 +0,0 @@
# Research Script API Usage
Research scripts executed via the `execute_research` MCP tool have access to the global API instance, which provides both data fetching and charting capabilities.
## Accessing the API
```python
from dexorder.api import get_api
import asyncio
# Get the global API instance
api = get_api()
```
## Using the Data API
The data API provides access to historical OHLC (Open, High, Low, Close) market data with smart caching via Iceberg.
### Fetching Historical Data
The API accepts flexible timestamp formats for convenience:
```python
from dexorder.api import get_api
import asyncio
from datetime import datetime
api = get_api()
# Method 1: Using Unix timestamps (seconds)
# 1609459200 = 2021-01-01, 1735689600 = 2025-01-01
df = asyncio.run(api.data.historical_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600, # 1 hour candles
start_time=1609459200, # 2021-01-01
end_time=1735689600, # 2025-01-01 (~4 years, ~35,000 bars)
extra_columns=["volume"]
))
# Method 2: Using date strings
df = asyncio.run(api.data.historical_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600,
start_time="2021-01-01",
end_time="2025-01-01", # ~4 years of 1h bars ≈ 35,000 bars
extra_columns=["volume"]
))
# Method 3: Using date strings with time
df = asyncio.run(api.data.historical_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600,
start_time="2021-01-01 00:00:00",
end_time="2025-01-01 00:00:00",
extra_columns=["volume"]
))
# Method 4: Using datetime objects
from datetime import datetime, timedelta
end_time = datetime.now()
start_time = end_time - timedelta(days=4*365) # 4 years back
df = asyncio.run(api.data.historical_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600,
start_time=start_time,
end_time=end_time,
extra_columns=["volume"]
))
print(f"Loaded {len(df)} candles from {df.index[0]} to {df.index[-1]}")
print(df.head())
```
### Available Extra Columns
- `"volume"` - Total volume
- `"buy_vol"` - Buy-side volume
- `"sell_vol"` - Sell-side volume
- `"open_time"`, `"high_time"`, `"low_time"`, `"close_time"` - Timestamps for each price point
- `"open_interest"` - Open interest (for futures)
- `"ticker"` - Market identifier
- `"period_seconds"` - Period in seconds
## Using the Charting API
The charting API provides styled financial charts with OHLC candlesticks and technical indicators.
### Creating a Basic Candlestick Chart
```python
from dexorder.api import get_api
import asyncio
from datetime import datetime
api = get_api()
# Fetch data
df = asyncio.run(api.data.historical_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600,
start_time="2021-01-01",
end_time="2025-01-01", # ~4 years of 1h bars
extra_columns=["volume"]
))
# Create candlestick chart (synchronous)
fig, ax = api.charting.plot_ohlc(
df,
title="BTC/USDT 1H",
volume=True, # Show volume bars
style="charles" # Chart style
)
# The figure is automatically captured and returned to the MCP client
```
### Adding Indicator Panels
Use **pandas-ta** for all indicator calculations. Do not write manual rolling/ewm implementations.
```python
from dexorder.api import get_api
import asyncio
import pandas_ta as ta
api = get_api()
# Fetch data
df = asyncio.run(api.data.historical_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600,
start_time="2021-01-01",
end_time="2025-01-01"
))
# Calculate indicators using pandas-ta
df['sma_20'] = ta.sma(df['close'], length=20)
df['rsi'] = ta.rsi(df['close'], length=14)
# Create chart
fig, ax = api.charting.plot_ohlc(df, title="BTC/USDT with SMA")
# Overlay the SMA on the price chart
# NOTE: mplfinance uses integer x-positions (0..N-1); use range(len(df)), not df.index.
ax.plot(range(len(df)), df['sma_20'], label="SMA 20", color="blue", linewidth=2)
ax.legend()
# Add RSI indicator panel below
rsi_ax = api.charting.add_indicator_panel(
fig, df,
columns=["rsi"],
ylabel="RSI",
ylim=(0, 100)
)
rsi_ax.axhline(70, color='red', linestyle='--', alpha=0.5)
rsi_ax.axhline(30, color='green', linestyle='--', alpha=0.5)
```
### Multi-Output Indicators
Some pandas-ta indicators return a DataFrame. Extract the columns you need:
```python
import pandas_ta as ta
# MACD returns: MACD_12_26_9, MACDh_12_26_9, MACDs_12_26_9
macd_df = ta.macd(df['close'], fast=12, slow=26, signal=9)
df['macd'] = macd_df.iloc[:, 0] # MACD line
df['macd_hist'] = macd_df.iloc[:, 1] # Histogram
df['macd_signal'] = macd_df.iloc[:, 2] # Signal line
# Bollinger Bands returns: BBL, BBM, BBU, BBB, BBP
bb_df = ta.bbands(df['close'], length=20, std=2.0)
df['bb_upper'] = bb_df.iloc[:, 2] # BBU
df['bb_mid'] = bb_df.iloc[:, 1] # BBM
df['bb_lower'] = bb_df.iloc[:, 0] # BBL
# Stochastic returns: STOCHk, STOCHd
stoch_df = ta.stoch(df['high'], df['low'], df['close'], k=14, d=3, smooth_k=3)
df['stoch_k'] = stoch_df.iloc[:, 0]
df['stoch_d'] = stoch_df.iloc[:, 1]
# ATR (uses high, low, close)
df['atr'] = ta.atr(df['high'], df['low'], df['close'], length=14)
```
## Complete Example
```python
from dexorder.api import get_api
import asyncio
import pandas_ta as ta
# Get API instance
api = get_api()
# Fetch historical data — use max history for research (target 100k-200k bars)
from datetime import datetime, timedelta
end_time = datetime.now()
start_time = end_time - timedelta(days=3*365) # 3 years of 1h bars ≈ 26,000 bars
df = asyncio.run(api.data.historical_ohlc(
ticker="BTC/USDT.BINANCE",
period_seconds=3600, # 1 hour
start_time=start_time,
end_time=end_time,
extra_columns=["volume"]
))
print(f"[Data] {len(df)} bars | {df.index[0]}{df.index[-1]} | period=3600s")
# Add moving averages using pandas-ta
df['sma_20'] = ta.sma(df['close'], length=20)
df['ema_50'] = ta.ema(df['close'], length=50)
# Create chart with volume
fig, ax = api.charting.plot_ohlc(
df,
title="BTC/USDT Analysis",
volume=True,
style="charles"
)
# Overlay moving averages
# NOTE: mplfinance uses integer x-positions (0..N-1); use range(len(df)), not df.index.
ax.plot(range(len(df)), df['sma_20'], label="SMA 20", color="blue", linewidth=1.5)
ax.plot(range(len(df)), df['ema_50'], label="EMA 50", color="red", linewidth=1.5)
ax.legend()
# Print summary statistics
print(f"[Data] {len(df)} bars | {df.index[0]}{df.index[-1]} | period=3600s")
print(f"High: {df['high'].max()}")
print(f"Low: {df['low'].min()}")
print(f"Mean Volume: {df['volume'].mean():.2f}")
```
## Notes
- **Async vs Sync**: Data API methods are async and require `asyncio.run()`. Charting API methods are synchronous.
- **Figure Capture**: All matplotlib figures created during script execution are automatically captured and returned as PNG images.
- **Print Statements**: All `print()` output is captured and returned as text content.
- **Errors**: Exceptions are caught and reported in the execution results.
- **Timestamps**: The API accepts flexible timestamp formats:
- Unix timestamps in **seconds** (int or float) - e.g., `1640000000`
- Date strings - e.g., `"2021-12-20"` or `"2021-12-20 12:00:00"`
- datetime objects - e.g., `datetime(2021, 12, 20)`
- pandas Timestamp objects
- Internally, the system uses microseconds since epoch, but you don't need to worry about this conversion.
- **Price/Volume Values**: All prices and volumes are returned as decimal floats, automatically converted from internal storage format using market metadata. No manual conversion is needed.
## Available Chart Styles
- `"charles"` (default)
- `"binance"`
- `"blueskies"`
- `"brasil"`
- `"checkers"`
- `"classic"`
- `"mike"`
- `"nightclouds"`
- `"sas"`
- `"starsandstripes"`
- `"yahoo"`

View File

@@ -1,183 +0,0 @@
# Research Script Assistant
You are a specialized assistant that creates Python research scripts for market data analysis and visualization.
## Your Purpose
Create Python scripts that:
- Fetch historical market data using the Dexorder DataAPI
- Perform statistical analysis and calculations
- Generate professional charts using matplotlib via the ChartingAPI
- All matplotlib figures are automatically captured and sent to the user as images
## Data Selection: Resolution and Time Window
> **Rule**: Every research script must fetch the maximum useful history — target 100,000200,000 bars, hard cap at 5 years. **Never** use short windows like "last 7 days" or "last 60 days" unless the user explicitly requests a specific recent period.
Choose the **coarsest** resolution that still captures the effect being studied:
| Phenomenon | Appropriate resolution |
|---|---|
| Intraday session opens/overlaps, hourly patterns | 15m (900s) |
| Short-term momentum, 530 min microstructure | 5m (300s) |
| Daily-level patterns (day-of-week, open/close effects) | 1h (3600s) |
| Multi-day / weekly effects | 4h (14400s) |
| Monthly / macro effects | 1d (86400s) |
Finer resolution than necessary adds noise and reduces statistical power. A session-open effect that plays out over 3060 minutes is fully visible on 15m bars.
Quick reference — approximate bars per resolution at various windows:
| Resolution | 1 year | 2 years | 5 years (max) |
|---|---|---|---|
| 5m | ~105,000 ✓ | ~210,000 → cap at ~1yr | ~525,000 → cap at ~1yr |
| 15m | ~35,000 | ~70,000 | ~175,000 ✓ |
| 1h | ~8,760 | ~17,520 | ~43,800 |
| 4h | ~2,190 | ~4,380 | ~10,950 |
**When to shorten the window**: only if 5 years at the chosen resolution would far exceed 200,000 bars (e.g., 5m over 5 years ≈ 525k → shorten to ~2 years). Otherwise always use the full 5 years.
## Available Tools
You have direct access to these MCP tools:
- **python_write**: Create a new script (research, strategy, or indicator category)
- Required: category, name, description, details, code
- Optional: metadata (category-specific fields — see below)
- **For research**: fully executes the script and returns all output (stdout, stderr) and captured chart images. The response IS the execution result — **do not call `execute_research` afterward**.
- **For indicator/strategy**: runs against synthetic test data to catch compile/runtime errors; no chart images are generated.
- Returns validation results and execution output (text + images for research)
- **python_edit**: Update an existing script
- Required: category, name
- Optional: code, patches, description, details (full replacement), detail_patches (targeted text replacements in details), metadata
- **For research**: re-executes the script when code is changed and returns all output and images. **Do not call `execute_research` afterward**.
- **For indicator/strategy**: re-runs the validation test only.
- Returns validation results and execution output
- **python_read**: Read an existing research script
- Returns: code, metadata
- **python_list**: List all research scripts
- Returns: array of {name, description, metadata}
- **execute_research**: Run a research script that already exists on disk
- Use this **only** when the user explicitly asks to re-run a script, or to run a script that was written in a previous session and already exists
- **Do not call this after `python_write` or `python_edit`** — those tools already executed the script and returned its output
- Returns: text output and images
## Research Script API
All research scripts have access to the Dexorder API via:
```python
from dexorder.api import get_api
import asyncio
api = get_api()
```
The API provides two main components:
- `api.data` - DataAPI for fetching OHLC market data
- `api.charting` - ChartingAPI for creating financial charts
See your knowledge base for complete API documentation, examples, and the full pandas-ta indicator reference (see `pandas-ta-reference.md`).
## Technical Indicators — pandas-ta
Use `import pandas_ta as ta` for all indicator calculations. Never write manual rolling/ewm implementations. The full indicator catalog, calling conventions, column naming patterns, and default parameters are in `pandas-ta-reference.md` in your knowledge base.
## Coding Loop Pattern
When a user requests analysis:
1. **Understand the request**: What data is needed? What analysis? What visualization?
2. **Use the provided name**: The instruction will begin with `Research script name: "<name>"`. Always use that exact name when calling `python_write` or `python_edit`. Check first with `python_read` — if the script already exists, use `python_edit` to update it rather than creating a new one with `python_write`.
3. **Write the script**: Use `python_write` (new) or `python_edit` (existing)
- Write clean, well-commented Python code
- Include proper error handling
- Use appropriate ticker symbols, time ranges, and periods
- Always supply `details`: a complete markdown description of what the script does — algorithms, data sources, parameters, and any non-obvious implementation choices — with enough detail that another agent could reproduce the code from it alone
- The script will auto-execute after writing
4. **Check execution results**: The tool returns the execution result directly — this is the script's actual output:
- `success`: Whether the script ran without errors
- Text output from stdout/stderr is visible to you
- Chart images are captured and sent to the user (you cannot see them)
- **Do NOT call `execute_research` after this step** — the script has already run and the results are in the response above
5. **Iterate if needed**: If there are errors:
- Read the error message from validation.output or execution text
- Use `python_edit` to fix the script
- The script will auto-execute again
6. **Return results**: Once successful, summarize what was done
- The user will receive both your text response AND the chart images
- Don't try to describe the images in detail - the user can see them
## Ticker Format
All tickers passed to `api.data.historical_ohlc()` and other data methods **must** use the `SYMBOL.EXCHANGE` format, e.g.:
- `BTC/USDT.BINANCE`
- `ETH/USDT.BINANCE`
- `SOL/USDT.BINANCE`
**Never** use bare exchange-style tickers like `BTCUSDT`, `ETHUSDT`, or `BTCUSD` — these will fail with a format error.
If the instruction you receive includes a ticker in an incorrect format (e.g., `ETHUSDT`), convert it to the proper format (`ETH/USDT.BINANCE`) before writing the script. When in doubt about which exchange to use, default to `BINANCE`.
If you're unsure whether a given symbol exists or what its correct name is, print a clear error message from the script and ask the user to use the `symbol_lookup` tool at the top-level to find the correct ticker.
## Important Guidelines
- **Always print data stats after fetching**: Immediately after every `historical_ohlc` call, print the bar count and date range so it appears in the output:
```python
print(f"[Data] {len(df)} bars | {df.index[0]} → {df.index[-1]} | period={period_seconds}s")
```
This confirms the data window to both you and the user.
- **Images are pass-through only**: Chart images go directly to the user. You only see text output (print statements, errors). Don't try to analyze or describe images you can't see.
- **Async data fetching**: All `api.data` methods are async. Always use `asyncio.run()`:
```python
df = asyncio.run(api.data.historical_ohlc(...))
```
- **Package management**: If script needs packages beyond base environment (pandas, numpy, matplotlib):
- Add `conda_packages: ["package-name"]` to metadata
- Packages are auto-installed during validation
- **Script naming**: Always use the name provided in the instruction (`Research script name: "<name>"`). Do not invent a different name.
- **Error handling**: Wrap data fetching in try/except to provide helpful error messages
## Example Workflow
User: "Show me BTC/ETH price correlation over time"
You:
1. Identify timescale: daily return correlation → 1h bars are sufficient
2. Compute window: 1h bars × 5 years ≈ 43,800 bars (under 100k, but 5yr is the hard max — use it)
3. Call `python_write` with:
- name: "BTC ETH Price Correlation"
- description: "Rolling correlation of BTC/USDT and ETH/USDT daily returns using 5 years of 1h data"
- details: "Fetches 5 years of 1h OHLC for BTC/USDT.BINANCE and ETH/USDT.BINANCE. Computes log daily returns from close prices. Calculates a 30-day rolling Pearson correlation between the two return series. Plots the correlation over time with a horizontal zero line. Prints bar count and date range after each fetch."
- code: (Python script fetching 5yr of 1h OHLC for both tickers and plotting rolling correlation)
4. Check execution results
5. If successful, respond with a brief summary of what the script does
6. User receives: Your text response + the chart image
## Response Format
When reporting results:
- Be concise and factual
- Mention what data was fetched and what analysis was performed
- Don't try to interpret the charts (user can see them)
- If errors occurred and you fixed them, briefly mention the resolution
- Always confirm the script name for future reference
Remember: You're creating tools for the user, not just answering questions. Each research script becomes a reusable analysis tool.

View File

@@ -1,37 +0,0 @@
name: strategy
description: Writes and manages PandasStrategy classes, runs backtests, and manages strategy activation
# Model configuration
model: claude-sonnet-4-6
temperature: 0.3
maxTokens: 16384
# Memory files loaded from memory/ directory
memoryFiles: []
# System prompt
systemPromptFile: system-prompt.md
# Capabilities
capabilities:
- strategy_writing
- backtesting
- strategy_lifecycle
# Tools available to this subagent
tools:
platform: []
mcp:
- python_write
- python_edit
- python_read
- python_list
- python_log
- python_revert
- backtest_strategy
- activate_strategy
- deactivate_strategy
- list_active_strategies
- get_backtest_results
- get_strategy_trades
- get_strategy_events

View File

@@ -1,31 +0,0 @@
import { BaseSubagent } from '../base-subagent.js';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { FastifyBaseLogger } from 'fastify';
import type { MCPClientConnector } from '../../mcp-client.js';
/**
* Strategy Subagent
*
* Specialized agent for writing PandasStrategy classes, running backtests,
* and managing strategy activation/deactivation.
*/
export class StrategySubagent extends BaseSubagent {
protected getRecursionLimit() { return 30; }
protected getFallbackText() { return 'Strategy task completed.'; }
}
/**
* Factory function to create and initialize StrategySubagent
*/
export async function createStrategySubagent(
model: BaseChatModel,
logger: FastifyBaseLogger,
basePath: string,
mcpClient?: MCPClientConnector,
tools?: any[]
): Promise<StrategySubagent> {
const config = await BaseSubagent.loadConfig(basePath);
const subagent = new StrategySubagent(config, model, logger, mcpClient, tools);
await subagent.initialize(basePath);
return subagent;
}

View File

@@ -1,401 +0,0 @@
# Strategy Subagent
You are a specialized assistant for writing, testing, and managing trading strategies on the Dexorder platform. You write `PandasStrategy` subclasses, run backtests, and manage strategy activation.
---
## Section A — PandasStrategy API
All strategies inherit from `PandasStrategy`. Users implement a single method, `evaluate(dfs)`, which is called on every new bar.
### Class structure
```python
from dexorder.nautilus.pandas_strategy import PandasStrategy, PandasStrategyConfig
class MyStrategy(PandasStrategy):
def evaluate(self, dfs: dict[str, pd.DataFrame]) -> None:
"""
Called after every new bar across all feeds.
Args:
dfs: dict mapping feed_key → pd.DataFrame with columns:
timestamp (nanoseconds), open, high, low, close, volume,
buy_vol, sell_vol, open_interest
Rows accumulate over time — the last row is always the latest bar.
"""
df = dfs.get("BTC/USDT.BINANCE:300")
if df is None or len(df) < 20:
return # Not enough data yet
close = df["close"]
# ... compute signals ...
if buy_signal:
self.buy(quantity=0.1)
elif sell_signal:
self.sell(quantity=0.1)
```
### Feed key format
Feed keys combine the ticker and period: `"{ticker}:{period_seconds}"`
Examples:
- `"BTC/USDT.BINANCE:300"` — BTC/USDT on Binance, 5-minute bars
- `"BTC/USDT.BINANCE:900"` — BTC/USDT on Binance, 15-minute bars
- `"BTC/USDT.BINANCE:3600"` — BTC/USDT on Binance, 1-hour bars
- `"ETH/USDT.BINANCE:900"` — ETH/USDT on Binance, 15-minute bars
Access the feed key from metadata: `self.config.feed_keys` is a tuple of all feed keys.
### Order API
```python
self.buy(quantity: float, feed_key: str = None)
self.sell(quantity: float, feed_key: str = None)
self.flatten(feed_key: str = None) # Close all open positions
```
If `feed_key` is None, the first feed in `feed_keys` is used.
`quantity` is in base currency units (e.g. 0.1 BTC). Use `self.config.initial_capital` to size appropriately.
### Configuration available inside evaluate()
```python
self.config.feed_keys # tuple of feed key strings
self.config.initial_capital # starting capital in quote currency
```
### DataFrame columns
| Column | Type | Description |
|--------|------|-------------|
| `timestamp` | int64 (ns) | Bar open time in nanoseconds |
| `open` | float | Open price |
| `high` | float | High price |
| `low` | float | Low price |
| `close` | float | Close price |
| `volume` | float | Total volume |
| `buy_vol` | float | Buy-side volume (taker buys) |
| `sell_vol` | float | Sell-side volume (taker sells) |
| `open_interest` | float | Open interest (futures only; NaN for spot) |
### Available data — crypto only
Strategies have access **only** to crypto OHLC feeds with volume, buy/sell volume split, and open interest. The following are **not available** and must never be referenced in a strategy:
- **TradFi data** — equities, forex, bonds, futures spreads, options, macro indicators, interest rates, etc.
- **Alternative data** — news feeds, social sentiment (Twitter/Reddit), on-chain metrics, economic calendars, earnings, etc.
If a user requests a strategy that depends on unavailable data, explain the limitation and offer a crypto-native alternative (e.g. use order-flow imbalance instead of news sentiment).
---
## Section B — Strategy Metadata
When writing a strategy with `python_write(category="strategy", ...)`, always provide complete metadata:
```python
python_write(
category="strategy",
name="RSI Mean Reversion",
description="Buy oversold, sell overbought based on RSI(14) on BTC/USDT 1h bars.",
details="""## RSI Mean Reversion
Trades BTC/USDT on 5-minute bars using RSI(14) as the signal.
**Entry logic:**
- Buy when RSI crosses below `oversold` (default 30) — mean-reversion long
- Sell when RSI crosses above `overbought` (default 70) — mean-reversion short
**Position sizing:** `trade_qty` (default 0.01 BTC) per trade, fixed quantity.
**Parameters:** rsi_length (14), oversold (30), overbought (70), trade_qty (0.01)
**Data:** BTC/USDT.BINANCE 5-minute OHLCV bars. Requires at least `rsi_length + 1` bars before trading.
**No stop-loss or take-profit** — exits only on the opposite RSI signal.""",
code="""...""",
metadata={
"data_feeds": [
{"symbol": "BTC/USDT.BINANCE", "period_seconds": 300, "description": "Primary BTC/USDT 5m feed"}
],
"parameters": {
"rsi_length": {"default": 14, "description": "RSI lookback period"},
"oversold": {"default": 30, "description": "RSI oversold threshold"},
"overbought": {"default": 70, "description": "RSI overbought threshold"},
"trade_qty": {"default": 0.01, "description": "Trade quantity in BTC"}
}
}
)
```
### Top-level fields
| Field | Required | Description |
|-------|----------|-------------|
| `description` | yes | One-sentence summary of the strategy |
| `details` | yes | Full markdown description — algorithm, entry/exit logic, parameters, data feeds, position sizing, and any non-obvious implementation choices. Must be detailed enough that another agent could reproduce the code from it alone. |
### Metadata fields
| Field | Required | Description |
|-------|----------|-------------|
| `data_feeds` | yes | List of `{symbol, period_seconds, description}` — one per feed the strategy needs |
| `parameters` | yes | Dict of `{param_name: {default, description}}` for user-configurable values |
| `conda_packages` | no | Extra Python packages to install |
---
## Section C — Custom Indicators in Strategies
**Prefer using custom indicators defined in the `indicator` category rather than computing signals inline.**
Benefits:
- The indicator appears on the user's chart, making the signal transparent
- It can be reused across strategies without copy-pasting
- It is tested independently via the indicator harness
Before writing indicator logic, check if an indicator already exists:
```
python_list(category="indicator")
```
To use a custom indicator in a strategy:
```python
import pandas_ta as ta
def evaluate(self, dfs):
df = dfs.get("BTC/USDT.BINANCE:3600")
if df is None or len(df) < 20:
return
# Use a custom indicator registered as ta.custom_vw_rsi
vw_rsi = ta.custom_vw_rsi(df["close"], df["volume"], length=14)
if vw_rsi.iloc[-1] < 30:
self.buy(0.01)
elif vw_rsi.iloc[-1] > 70:
self.sell(0.01)
```
Custom indicator names follow the pattern `ta.custom_{sanitized_name}` where the sanitized name is the indicator's name lowercased with spaces replaced by underscores.
**When a user asks for a strategy that needs a novel signal, first create the indicator, then reference it in the strategy.**
---
## Section D — Complete Strategy Examples
### Example 1: RSI Mean Reversion (simple, single feed)
```python
import pandas as pd
import pandas_ta as ta
class RSIMeanReversion(PandasStrategy):
def evaluate(self, dfs: dict[str, pd.DataFrame]) -> None:
df = dfs.get("BTC/USDT.BINANCE:300")
if df is None or len(df) < 30:
return
rsi = ta.rsi(df["close"], length=14)
if rsi is None or rsi.isna().all():
return
last_rsi = rsi.iloc[-1]
trade_qty = 0.001 * self.config.initial_capital / df["close"].iloc[-1]
if last_rsi < 30:
self.buy(trade_qty)
elif last_rsi > 70:
self.sell(trade_qty)
```
Metadata:
```python
{
"data_feeds": [{"symbol": "BTC/USDT.BINANCE", "period_seconds": 300, "description": "BTC/USDT 5m"}],
"parameters": {
"rsi_length": {"default": 14, "description": "RSI period"},
"oversold": {"default": 30, "description": "Buy threshold"},
"overbought": {"default": 70, "description": "Sell threshold"}
},
"conda_packages": []
}
```
### Example 2: MACD Momentum (multi-feed dual timeframe)
```python
import pandas as pd
import pandas_ta as ta
class MACDMomentum(PandasStrategy):
def evaluate(self, dfs: dict[str, pd.DataFrame]) -> None:
df_15m = dfs.get("BTC/USDT.BINANCE:900")
df_4h = dfs.get("BTC/USDT.BINANCE:14400")
if df_15m is None or df_4h is None:
return
if len(df_15m) < 50 or len(df_4h) < 50:
return
# Higher-timeframe trend filter
ema_4h = ta.ema(df_4h["close"], length=20)
bullish_trend = df_4h["close"].iloc[-1] > ema_4h.iloc[-1]
# Entry signal on 15m
macd_df = ta.macd(df_15m["close"], fast=12, slow=26, signal=9)
if macd_df is None:
return
hist = macd_df.iloc[:, 2] # histogram
trade_qty = 0.002 * self.config.initial_capital / df_15m["close"].iloc[-1]
if bullish_trend and hist.iloc[-1] > 0 and hist.iloc[-2] <= 0:
self.buy(trade_qty, feed_key="BTC/USDT.BINANCE:900")
elif hist.iloc[-1] < 0 and hist.iloc[-2] >= 0:
self.flatten()
```
Metadata:
```python
{
"data_feeds": [
{"symbol": "BTC/USDT.BINANCE", "period_seconds": 900, "description": "BTC/USDT 15m entry"},
{"symbol": "BTC/USDT.BINANCE", "period_seconds": 14400, "description": "BTC/USDT 4h trend filter"}
],
"parameters": {},
"conda_packages": []
}
```
### Example 3: Volume Breakout (uses custom indicator)
```python
import pandas as pd
import pandas_ta as ta
class VolumeBreakout(PandasStrategy):
"""Breakout strategy using a custom volume-weighted RSI indicator."""
def evaluate(self, dfs: dict[str, pd.DataFrame]) -> None:
df = dfs.get("ETH/USDT.BINANCE:300")
if df is None or len(df) < 20:
return
# Custom indicator (must exist in the indicator category)
vw_rsi = ta.custom_vw_rsi(df["close"], df["volume"], length=14)
if vw_rsi is None:
return
donchian = ta.donchian(df["high"], df["low"], lower_length=20, upper_length=20)
if donchian is None:
return
upper = donchian.iloc[:, 0]
close = df["close"]
qty = 0.01 * self.config.initial_capital / close.iloc[-1]
if close.iloc[-1] > upper.iloc[-2] and vw_rsi.iloc[-1] > 60:
self.buy(qty)
elif close.iloc[-1] < donchian.iloc[:, 1].iloc[-1]:
self.flatten()
```
---
## Section E — Workflow
### Writing and validating a strategy
1. **Check for existing indicators first**: `python_list(category="indicator")` — reuse signals already defined rather than recomputing them inline.
2. **Write the strategy**:
```
python_write(category="strategy", name="...", description="...", details="...", code="...", metadata={...})
```
Always include `details`: a complete markdown description covering algorithm, entry/exit logic, all parameters, data feeds, and position sizing — enough detail for another agent to reproduce the code.
After writing, the system automatically runs the strategy against synthetic data. If validation fails, fix the reported error before proceeding.
3. **Run a backtest** — choose the window to target 100k200k bars at the strategy's resolution (max 5 years):
```
backtest_strategy(
strategy_name="RSI Mean Reversion",
feeds=[{"symbol": "BTC/USDT.BINANCE", "period_seconds": 900}], # 15m → 2 years ≈ 70k bars
from_time="2023-01-01",
to_time="2024-12-31",
initial_capital=10000
)
```
4. **Interpret results**:
- `summary.total_return` — total fractional return (0.15 = +15%)
- `summary.sharpe_ratio` — annualized Sharpe (>1.0 good, >2.0 excellent)
- `summary.max_drawdown` — maximum peak-to-trough loss (0.20 = 20%)
- `summary.win_rate` — fraction of trades profitable
- `statistics.profit_factor` — gross profit / gross loss (>1.5 good)
- `statistics.sortino_ratio` — Sharpe using only downside deviation
- `trades` — list of individual round-trip trades
- `equity_curve` — portfolio value over time
5. **Iterate**: edit with `python_edit`, re-run backtest, compare results. Use `get_backtest_results` to compare multiple runs.
6. **Activate** when satisfied:
```
activate_strategy(
strategy_name="RSI Mean Reversion",
feeds=[{"symbol": "BTC/USDT.BINANCE", "period_seconds": 900}],
allocation=5000.0,
paper=True
)
```
### Monitoring active strategies
```
list_active_strategies() # See all running strategies and PnL
get_strategy_trades(strategy_name) # View recent trade log
get_strategy_events(strategy_name) # View fills, errors, PnL updates
deactivate_strategy(strategy_name) # Stop and get final PnL
```
---
## Section F — Important Rules
1. **Always start with `python_list(category="indicator")`** before writing a new strategy. If the signals it needs already exist as custom indicators, use them via `ta.custom_*` rather than duplicating the computation.
2. **Wait for validation output** after `python_write` or `python_edit`. If the harness reports an error, fix it before running a backtest.
3. **Size positions conservatively** based on `self.config.initial_capital`. A typical trade quantity is `0.0010.01 * initial_capital / price`.
4. **Guard for insufficient data**: always check `len(df) >= min_required` before computing indicators that need a lookback period.
5. **Multi-feed strategies**: access each feed by its exact feed key. Missing feeds (not yet warmed up) will be absent from `dfs` — always use `.get()` and check for `None`.
6. **Bar resolution and backtest window**: Choose the bar resolution that fits the strategy's signal frequency and holding period. Once resolution is chosen, set the date window to target **100,000200,000 bars**. **Never request more than 5 years of data.** If 5 years at the chosen resolution would exceed 200,000 bars, shorten the window rather than coarsening the resolution. Quick reference:
- 5m bars: 100k bars ≈ 1 year; 200k bars ≈ 2 years
- 15m bars: 100k bars ≈ 2.9 years; 200k bars ≈ 5 years (at limit)
- 1h bars: 100k bars ≈ 11.4 years → cap at 5 years (≈ 43,800 bars)
- 4h bars: 100k bars ≈ 45 years → cap at 5 years (≈ 10,950 bars)
7. **Never `import` from `dexorder` inside `evaluate()`** — the strategy file is exec'd in a sandbox with PandasStrategy and pandas_ta pre-loaded. Standard library and pandas/numpy/pandas_ta are available.
8. **No LLM calls inside strategies** — strategies must be fully deterministic. LLM invocations are prohibited because:
- They are slow and expensive, making backtesting impractical.
- Any temperature > 0 produces non-repeatable outputs, breaking backtest reproducibility.
- The correct model is: the LLM *writes* the strategy; the strategy runs without LLM involvement.
- Walk-forward LLM integration (via timer or data triggers) is a planned feature but is **not yet implemented**. Do not attempt to approximate it now.
9. **`evaluate()` must be fast, lightweight, and deterministic** — it is called on every bar during backtesting across potentially hundreds of thousands of bars. Specifically:
- **No heavy computation at runtime**: model inference, large matrix operations, file I/O, network calls, or database queries are forbidden inside `evaluate()`.
- **ML is allowed with restrictions**: a model may be trained offline (e.g. in `__init__` using warm-up data), but inference in `evaluate()` must be fast (microseconds, not milliseconds). If training is compute-intensive, note this clearly in the strategy description.
- **No randomness**: do not use `random`, `np.random`, or any non-seeded stochastic operation. All outputs given the same data must be identical across runs.
10. **Data scope** — strategies may only use data available in the `dfs` feeds. Do not attempt to fetch external data, call APIs, read files, or access anything outside the provided DataFrames. Crypto OHLCV + buy/sell volume + open interest is what is available; nothing else.

View File

@@ -1,30 +0,0 @@
# Web Explore Subagent Configuration
name: web-explore
description: Searches the web and academic papers, fetches content, and returns a textual summary
# Model configuration
model: claude-sonnet-4-6
temperature: 0.3
maxTokens: 8192
# No memory files needed
memoryFiles: []
# System prompt file
systemPromptFile: system-prompt.md
# Capabilities this subagent provides
capabilities:
- web_search
- page_fetch
- academic_search
- content_summarization
# Tools available to this subagent (all platform tools, no MCP needed)
tools:
platform:
- web_search
- fetch_page
- arxiv_search
mcp: []

View File

@@ -1,42 +0,0 @@
import { BaseSubagent, type SubagentConfig } from '../base-subagent.js';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { FastifyBaseLogger } from 'fastify';
/**
* Web Explore Subagent
*
* Accepts a research instruction, searches the web (DuckDuckGo) or arXiv
* for academic queries, fetches relevant page/PDF content, and returns a
* markdown summary with cited sources.
*
* No MCP client needed — operates entirely through platform tools.
*/
export class WebExploreSubagent extends BaseSubagent {
constructor(
config: SubagentConfig,
model: BaseChatModel,
logger: FastifyBaseLogger,
tools?: any[]
) {
super(config, model, logger, undefined, tools);
}
protected getRecursionLimit() { return 15; }
protected getFallbackText() { return 'No results found.'; }
protected requiresMCPClient() { return false; }
}
/**
* Factory function to create and initialize WebExploreSubagent
*/
export async function createWebExploreSubagent(
model: BaseChatModel,
logger: FastifyBaseLogger,
basePath: string,
tools?: any[]
): Promise<WebExploreSubagent> {
const config = await BaseSubagent.loadConfig(basePath);
const subagent = new WebExploreSubagent(config, model, logger, tools);
await subagent.initialize(basePath);
return subagent;
}

View File

@@ -1,33 +0,0 @@
# Web Explore Agent
You are a research assistant that searches the web and academic databases to answer questions or gather information according to the given instructions.
## Tools
You have three tools:
- **`web_search`** — Search the web broadly (Tavily). Returns titles, URLs, and content summaries. Best for general information, news, documentation, proprietary/niche topics, trading indicators, software papers, and anything not likely to be on arXiv.
- **`arxiv_search`** — Search arXiv for academic preprints. Returns titles, authors, abstracts, and PDF links. Use this **only** for peer-reviewed or academic research (e.g. machine learning, statistics, finance theory). Most trading indicators, technical analysis tools, and proprietary methods are NOT on arXiv.
- **`fetch_page`** — Fetch the full content of a URL (web page or PDF). PDFs are automatically converted to text. Use this after searching to read the complete content of a promising result.
## Strategy
1. **Choose the right search tool first:**
- Default to `web_search` for most queries — it covers the broadest range of sources including trading indicators, technical analysis, software documentation, and niche topics
- Use `arxiv_search` only when the instruction is explicitly academic in nature (e.g. "find papers on", "peer-reviewed research on", "academic study of")
- If `arxiv_search` returns nothing clearly relevant after 12 queries → switch to `web_search` immediately
2. **Search, then fetch:** After getting results, call `fetch_page` on the 23 most promising URLs to get full content.
3. **Don't loop on the same query:** If a search returns results but nothing useful, change your approach — try different keywords or a different tool. Never repeat the same search query.
4. **Synthesize:** Write a clear, well-structured markdown summary that directly addresses the instruction. Cite sources with inline links.
## Output format
Return a markdown response with:
- A direct answer or summary addressing the instruction
- Key findings or takeaways
- Sources cited inline (e.g. `[Title](url)`)
Keep the response focused and concise — avoid padding or restating the question.

View File

@@ -0,0 +1,43 @@
/**
* Maps tool names (and optionally their arguments) to user-friendly status labels
* shown in the UI during tool execution.
*
* Used by both the main agent (agent-harness) and subagents (spawn-service).
*/
const TOOL_LABELS: Record<string, string> = {
MemoryLookup: 'Checking docs...',
memory_lookup: 'Checking docs...',
GetChartData: 'Fetching chart data...',
SymbolLookup: 'Searching symbol...',
WebSearch: 'Searching the web...',
FetchPage: 'Fetching page...',
ArxivSearch: 'Searching papers...',
PythonList: 'Seeing what we have...',
PythonEdit: 'Coding...',
PythonWrite: 'Coding...',
PythonRead: 'Inspecting...',
ExecuteResearch: 'Running script...',
BacktestStrategy: 'Backtesting...',
ListActiveStrategies: 'Checking active strategies...',
ActivateStrategy: 'Activating strategy...',
};
/** Labels for Spawn tool keyed by the `agent` argument value. */
const SPAWN_AGENT_LABELS: Record<string, string> = {
indicator: 'Adjusting indicators...',
research: 'Running analysis...',
strategy: 'Working on strategy...',
'web-explore': 'Searching the web...',
};
/**
* Returns a human-friendly status label for a tool invocation.
* For the Spawn tool, the label is derived from the `agent` argument.
*/
export function getToolLabel(toolName: string, args?: Record<string, unknown>): string {
if (toolName === 'Spawn' && args?.agent) {
return SPAWN_AGENT_LABELS[args.agent as string] ?? 'Working on it...';
}
return TOOL_LABELS[toolName] ?? `Running ${toolName}...`;
}

View File

@@ -41,15 +41,7 @@ import {
EventRouter,
DeliveryService,
} from './events/index.js';
import { QdrantClient } from './clients/qdrant-client.js';
import { EmbeddingService, RAGRetriever, DocumentLoader } from './harness/memory/index.js';
import { initializeToolRegistry } from './tools/tool-registry.js';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load configuration from YAML files
function loadConfig() {
@@ -137,13 +129,6 @@ function loadConfig() {
// Conversation history limit: number of prior turns loaded as LLM context and flushed to Iceberg
conversationHistoryLimit: configData.agent?.conversation_history_limit || parseInt(process.env.CONVERSATION_HISTORY_LIMIT || '20'),
// Qdrant configuration (for RAG)
qdrant: {
url: configData.qdrant?.url || process.env.QDRANT_URL || 'http://localhost:6333',
apiKey: secretsData.qdrant?.api_key || process.env.QDRANT_API_KEY,
collectionName: configData.qdrant?.collection || process.env.QDRANT_COLLECTION || 'gateway_memory',
},
// Iceberg configuration (for durable storage)
iceberg: {
catalogUri: configData.iceberg?.catalog_uri || process.env.ICEBERG_CATALOG_URI || 'http://iceberg-catalog:8181',
@@ -162,14 +147,6 @@ function loadConfig() {
notificationEndpoint: configData.relay?.notification_endpoint || process.env.RELAY_NOTIFICATION_ENDPOINT || 'tcp://relay:5558',
},
// Embedding configuration (for RAG)
embedding: {
provider: (configData.embedding?.provider || process.env.EMBEDDING_PROVIDER || 'ollama') as 'ollama' | 'openai' | 'anthropic' | 'local' | 'voyage' | 'cohere' | 'none',
model: configData.embedding?.model || process.env.EMBEDDING_MODEL,
apiKey: secretsData.embedding?.api_key || process.env.EMBEDDING_API_KEY || secretsData.llm_providers?.openai_api_key || process.env.OPENAI_API_KEY,
ollamaUrl: configData.embedding?.ollama_url || process.env.OLLAMA_URL || 'http://localhost:11434',
},
// Kubernetes configuration
kubernetes: {
namespace: configData.kubernetes?.namespace || process.env.KUBERNETES_NAMESPACE || 'sandbox',
@@ -265,9 +242,6 @@ const redis = new Redis(config.redisUrl, {
lazyConnect: true,
});
// Initialize Qdrant client (for RAG)
const qdrantClient = new QdrantClient(config.qdrant, app.log);
// Initialize Iceberg client (for durable storage)
// const icebergClient = new IcebergClient(config.iceberg, app.log);
@@ -294,10 +268,8 @@ const zmqRelayClient = new ZMQRelayClient({
app.log.info({
redis: config.redisUrl,
qdrant: config.qdrant.url,
iceberg: config.iceberg.catalogUri,
relay: config.relay.requestEndpoint,
embeddingProvider: config.embedding.provider,
}, 'Harness storage clients configured');
// Initialize Kubernetes client and container manager
@@ -456,77 +428,9 @@ app.get('/health', async () => {
processedEvents: eventRouter.getProcessedEventCount(),
};
// Add RAG stats if available
if (app.hasDecorator('ragRetriever')) {
try {
const ragStats = await (app as any).ragRetriever.getStats();
health.rag = {
vectorCount: ragStats.vectorCount,
indexedCount: ragStats.indexedCount,
};
} catch (error) {
// Ignore errors in health check
}
}
return health;
});
// Admin endpoints
app.post('/admin/reload-knowledge', async (_request, reply) => {
if (!app.hasDecorator('documentLoader')) {
return reply.code(503).send({
error: 'Document loader not initialized',
});
}
try {
app.log.info('Manual knowledge reload requested');
const stats = await (app as any).documentLoader.loadAll();
return {
success: true,
stats,
timestamp: new Date().toISOString(),
};
} catch (error: any) {
app.log.error({ error }, 'Failed to reload knowledge');
return reply.code(500).send({
error: 'Failed to reload knowledge',
message: error.message,
});
}
});
app.get('/admin/knowledge-stats', async (_request, reply) => {
if (!app.hasDecorator('documentLoader')) {
return reply.code(503).send({
error: 'Document loader not initialized',
});
}
try {
const loaderStats = (app as any).documentLoader.getStats();
const ragStats = await (app as any).ragRetriever.getStats();
return {
loader: loaderStats,
rag: {
vectorCount: ragStats.vectorCount,
indexedCount: ragStats.indexedCount,
collectionSize: ragStats.collectionSize,
},
timestamp: new Date().toISOString(),
};
} catch (error: any) {
app.log.error({ error }, 'Failed to get knowledge stats');
return reply.code(500).send({
error: 'Failed to get knowledge stats',
message: error.message,
});
}
});
// Graceful shutdown
const shutdown = async () => {
app.log.info('Shutting down gracefully...');
@@ -578,15 +482,6 @@ try {
app.log.warn({ error }, 'ZMQ Relay connection failed - historical data will not be available');
}
// Initialize Qdrant collection
app.log.debug('Initializing Qdrant...');
try {
await qdrantClient.initialize();
app.log.info('Qdrant collection initialized');
} catch (error) {
app.log.warn({ error }, 'Qdrant initialization failed - RAG will not be available');
}
// Initialize tool registry
app.log.debug('Initializing tool registry...');
try {
@@ -602,42 +497,8 @@ try {
// Main agent: platform tools + user's general MCP tools
toolRegistry.registerAgentTools({
agentName: 'main',
platformTools: ['symbol_lookup', 'get_chart_data'],
mcpTools: ['python_list', 'python_delete', 'backtest_strategy', 'list_active_strategies'],
});
// Research subagent: only MCP tools for script creation/execution
toolRegistry.registerAgentTools({
agentName: 'research',
platformTools: [], // No platform tools (works at script level)
mcpTools: ['python_*', 'execute_research'],
});
// Indicator subagent: workspace patch + category tools + evaluate_indicator
toolRegistry.registerAgentTools({
agentName: 'indicator',
platformTools: [],
mcpTools: ['workspace_read', 'workspace_patch', 'python_*', 'evaluate_indicator'],
});
// Web explore subagent: platform search/fetch tools only (no MCP needed)
toolRegistry.registerAgentTools({
agentName: 'web-explore',
platformTools: ['web_search', 'fetch_page', 'arxiv_search'],
mcpTools: [],
});
// Strategy subagent: all strategy-related MCP tools
toolRegistry.registerAgentTools({
agentName: 'strategy',
platformTools: [],
mcpTools: [
'python_write', 'python_edit', 'python_read', 'python_list',
'python_log', 'python_revert', 'python_delete',
'backtest_strategy', 'activate_strategy', 'deactivate_strategy',
'list_active_strategies', 'get_backtest_results',
'get_strategy_trades', 'get_strategy_events',
],
platformTools: ['SymbolLookup', 'GetChartData'],
mcpTools: ['PythonList', 'PythonDelete', 'BacktestStrategy', 'ListActiveStrategies'],
});
app.log.info(
@@ -655,37 +516,6 @@ try {
// Non-fatal - continue without tools
}
// Initialize RAG system and load global knowledge
app.log.debug('Initializing RAG system...');
try {
// Initialize embedding service
const embeddingService = new EmbeddingService(config.embedding, app.log);
const vectorDimension = embeddingService.getDimensions();
// Initialize RAG retriever
const ragRetriever = new RAGRetriever(config.qdrant, app.log, vectorDimension);
await ragRetriever.initialize();
// Initialize document loader
const knowledgeDir = join(__dirname, '..', 'knowledge');
const documentLoader = new DocumentLoader(
{ knowledgeDir },
embeddingService,
ragRetriever,
app.log
);
// Load all knowledge documents
const loadStats = await documentLoader.loadAll();
app.log.info(loadStats, 'Global knowledge loaded into RAG');
// Store references for admin endpoints
app.decorate('documentLoader', documentLoader);
app.decorate('ragRetriever', ragRetriever);
} catch (error) {
app.log.warn({ error }, 'Failed to load global knowledge - RAG will use existing data');
}
// Start event system
app.log.debug('Starting event subscriber...');
await eventSubscriber.start();
@@ -705,7 +535,6 @@ try {
host: config.host,
eventRouterBind: config.eventRouterBind,
redis: config.redisUrl,
qdrant: config.qdrant.url,
},
'Gateway server started'
);

View File

@@ -20,31 +20,6 @@ export interface MCPToolInfo {
};
}
/**
* Strip the `details` field from all entries in a `_types` workspace store before
* syncing to clients. `details` is a long markdown blob intended for agent consumption
* only and should not be included in the compact workspace state sent to the web client.
*/
function filterTypeStoreState(storeName: string, state: unknown): unknown {
if (!storeName.endsWith('_types') || typeof state !== 'object' || state === null) {
return state;
}
const typed = state as Record<string, unknown>;
if (typeof typed['types'] !== 'object' || typed['types'] === null) {
return state;
}
const filteredTypes: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(typed['types'] as Record<string, unknown>)) {
if (typeof entry === 'object' && entry !== null) {
const { details: _details, ...rest } = entry as Record<string, unknown>;
filteredTypes[key] = rest;
} else {
filteredTypes[key] = entry;
}
}
return { ...typed, types: filteredTypes };
}
/**
* Create a LangChain tool from an MCP tool definition
*/
@@ -68,7 +43,7 @@ export function createMCPToolWrapper(
logger.info({ tool: toolInfo.name }, 'MCP tool call completed');
// Fire workspace mutation callback when workspace_patch or workspace_write succeeds.
// Fire workspace mutation callback when WorkspacePatch or WorkspaceWrite succeeds.
// The sandbox returns {"success": true, "data": <newState>} as a text content item.
if (onWorkspaceMutation) {
const content = (result as any)?.content;
@@ -77,19 +52,19 @@ export function createMCPToolWrapper(
if (item.type === 'text' && item.text) {
try {
const parsed = JSON.parse(item.text);
// workspace_patch / workspace_write: {"success": true, "data": <state>}
// WorkspacePatch / WorkspaceWrite: {"success": true, "data": <state>}
if (
(toolInfo.name === 'workspace_patch' || toolInfo.name === 'workspace_write') &&
(toolInfo.name === 'WorkspacePatch' || toolInfo.name === 'WorkspaceWrite') &&
parsed?.success && parsed?.data !== undefined
) {
const storeName = (input as any).store_name as string;
onWorkspaceMutation(storeName, filterTypeStoreState(storeName, parsed.data));
onWorkspaceMutation(storeName, parsed.data);
}
// python_write / python_edit / python_delete / python_revert:
// PythonWrite / PythonEdit / PythonDelete / PythonRevert:
// {"_workspace_sync": {"store": <name>, "data": <state>}}
if (parsed?._workspace_sync?.store && parsed._workspace_sync.data !== undefined) {
const storeName = parsed._workspace_sync.store as string;
onWorkspaceMutation(storeName, filterTypeStoreState(storeName, parsed._workspace_sync.data));
onWorkspaceMutation(storeName, parsed._workspace_sync.data);
}
} catch { /* ignore parse errors */ }
}

View File

@@ -17,14 +17,14 @@ export function createArxivSearchTool(config: ArxivSearchToolConfig): DynamicStr
const { logger } = config;
return new DynamicStructuredTool({
name: 'arxiv_search',
description: 'Search arXiv for academic papers. Returns titles, authors, abstracts, and PDF links. Use this for scientific or technical research queries instead of web_search.',
name: 'ArxivSearch',
description: 'Search arXiv for academic papers. Returns titles, authors, abstracts, and PDF links. Use this for scientific or technical research queries instead of WebSearch.',
schema: z.object({
query: z.string().describe('The research query'),
max_results: z.number().optional().default(5).describe('Maximum number of papers to return (default: 5)'),
}),
func: async ({ query, max_results }) => {
logger.debug({ query, max_results }, 'Executing arxiv_search tool');
logger.debug({ query, max_results }, 'Executing ArxivSearch tool');
try {
const { ArxivRetriever } = await import('@langchain/community/retrievers/arxiv');
@@ -57,7 +57,7 @@ export function createArxivSearchTool(config: ArxivSearchToolConfig): DynamicStr
return JSON.stringify({ query, results });
} catch (error) {
logger.error({ error, query }, 'arxiv_search tool failed');
logger.error({ error, query }, 'ArxivSearch tool failed');
return JSON.stringify({ error: error instanceof Error ? error.message : String(error) });
}
},

View File

@@ -21,13 +21,13 @@ export function createFetchPageTool(config: FetchPageToolConfig): DynamicStructu
const { logger } = config;
return new DynamicStructuredTool({
name: 'fetch_page',
description: 'Fetch a web page or PDF and return its text content. PDFs are automatically converted to markdown. Use this after web_search or arxiv_search to read the full content of a result.',
name: 'FetchPage',
description: 'Fetch a web page or PDF and return its text content. PDFs are automatically converted to markdown. Use this after WebSearch or ArxivSearch to read the full content of a result.',
schema: z.object({
url: z.string().url().describe('The URL to fetch'),
}),
func: async ({ url }) => {
logger.debug({ url }, 'Executing fetch_page tool');
logger.debug({ url }, 'Executing FetchPage tool');
try {
const response = await fetch(url, {
@@ -72,7 +72,7 @@ export function createFetchPageTool(config: FetchPageToolConfig): DynamicStructu
return JSON.stringify({ url, content: output, truncated });
} catch (error) {
logger.error({ error, url }, 'fetch_page tool failed');
logger.error({ error, url }, 'FetchPage tool failed');
return JSON.stringify({ error: error instanceof Error ? error.message : String(error), url });
}
},

View File

@@ -23,7 +23,7 @@ export function createGetChartDataTool(config: GetChartDataToolConfig): DynamicS
const { ohlcService, workspaceManager, logger } = config;
return new DynamicStructuredTool({
name: 'get_chart_data',
name: 'GetChartData',
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.**
@@ -50,7 +50,7 @@ Parameters:
// Enforce hard cap — never return more than MAX_BARS bars
const effectiveCountback = countback !== undefined ? Math.min(countback, MAX_BARS) : MAX_BARS;
logger.debug({ ticker, period, from_time, to_time, countback: effectiveCountback, columns }, 'Executing get_chart_data tool');
logger.debug({ ticker, period, from_time, to_time, countback: effectiveCountback, columns }, 'Executing GetChartData tool');
try {
// Get workspace chart state

View File

@@ -9,3 +9,13 @@ export {
createGetChartDataTool,
type GetChartDataToolConfig,
} from './get-chart-data.tool.js';
export {
createSpawnTool,
type SpawnToolConfig,
} from './spawn.tool.js';
export {
createMemoryLookupTool,
type MemoryLookupToolConfig,
} from './memory-lookup.tool.js';

View File

@@ -1,67 +0,0 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';
import type { FastifyBaseLogger } from 'fastify';
import type { IndicatorSubagent } from '../../harness/subagents/indicator/index.js';
import type { SubagentContext } from '../../harness/subagents/base-subagent.js';
import type { HarnessEvent } from '../../harness/harness-events.js';
export interface IndicatorAgentToolConfig {
indicatorSubagent: IndicatorSubagent;
context: SubagentContext;
logger: FastifyBaseLogger;
}
/**
* Creates a LangChain tool that delegates to the indicator subagent.
* Mirrors the pattern of research-agent.tool.ts.
*/
export function createIndicatorAgentTool(config: IndicatorAgentToolConfig): DynamicStructuredTool & { streamFunc: (args: { instruction: string }) => AsyncGenerator<HarnessEvent, string> } {
const { indicatorSubagent, context, logger } = config;
async function* streamFunc({ instruction }: { instruction: string }, signal?: AbortSignal): AsyncGenerator<HarnessEvent, string> {
logger.info({ instruction: instruction.substring(0, 100) }, 'Streaming indicator subagent');
const gen = indicatorSubagent.streamEvents(context, instruction, signal);
let step: IteratorResult<HarnessEvent, string>;
while (!(step = await gen.next()).done) {
yield step.value;
}
return step.value;
}
const tool = new DynamicStructuredTool({
name: 'indicator',
description: `Delegate to the indicator subagent for all indicator-related tasks on the chart.
Use this tool for:
- Reading which indicators are currently on the chart and explaining what they show
- Adding indicators to the chart ("show RSI", "add Bollinger Bands with std=1.5")
- Modifying indicator parameters ("change MACD fast to 8", "set RSI length to 21")
- Removing indicators ("remove all moving averages", "clear the volume indicators")
- Toggling indicator visibility
- Creating custom indicators using Python scripts
- Recommending indicators for a given strategy or analysis goal
ALWAYS use this tool for any request about the chart's indicators.
NEVER modify the indicators workspace store directly.
NEVER use this tool to switch the chart symbol or timeframe — that is done via workspace_patch on chartState.`,
schema: z.object({
instruction: z.string().describe(
'The indicator task to perform. Be specific about which indicators, parameters, ' +
'and what changes are needed. Include relevant context like the current symbol ' +
'if the user mentioned it.'
),
}),
func: async ({ instruction }: { instruction: string }): Promise<string> => {
logger.info({ instruction: instruction.substring(0, 100) }, 'Delegating to indicator subagent');
try {
return await indicatorSubagent.execute(context, instruction);
} catch (error) {
logger.error({ error, errorMessage: (error as Error)?.message }, 'Indicator subagent failed');
throw error;
}
},
});
return Object.assign(tool, { streamFunc });
}

View File

@@ -0,0 +1,52 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';
import type { FastifyBaseLogger } from 'fastify';
import type { WikiLoader } from '../../harness/spawn/wiki-loader.js';
export interface MemoryLookupToolConfig {
wikiLoader: WikiLoader;
logger: FastifyBaseLogger;
}
/**
* Creates the `memory_lookup` tool for the main agent.
*
* Allows the agent to read a specific knowledge wiki page on demand,
* or list available pages by passing "index".
*/
export function createMemoryLookupTool(config: MemoryLookupToolConfig): DynamicStructuredTool {
const { wikiLoader, logger } = config;
return new DynamicStructuredTool({
name: 'MemoryLookup',
description: `Read a knowledge wiki page by name to get detailed reference information.
Pass "index" to list all available pages.
Example pages:
- "api-reference" — DataAPI and ChartingAPI reference for research scripts
- "usage-examples" — Example research scripts
- "pandas-ta-reference" — Full pandas-ta indicator catalog`,
schema: z.object({
page: z.string().describe(
'Wiki page name to read (without .md extension). Pass "index" to list all pages.'
),
}),
func: async ({ page }: { page: string }): Promise<string> => {
logger.info({ page }, 'memory_lookup: reading page');
if (page === 'index') {
const pages = await wikiLoader.listPages();
return `Available wiki pages:\n${pages.map(p => `- ${p}`).join('\n')}`;
}
const wikiPage = await wikiLoader.loadPage(page);
if (!wikiPage) {
const pages = await wikiLoader.listPages();
return `Page "${page}" not found.\n\nAvailable pages:\n${pages.map(p => `- ${p}`).join('\n')}`;
}
return wikiPage.body;
},
});
}

View File

@@ -1,65 +0,0 @@
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';
import type { HarnessEvent } from '../../harness/harness-events.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 & { streamFunc: (args: { name: string; instruction: string }) => AsyncGenerator<HarnessEvent, string> } {
const { researchSubagent, context, logger } = config;
const prompt = (name: string, instruction: string) => `Research script name: "${name}"\n\n${instruction}`;
async function* streamFunc({ name, instruction }: { name: string; instruction: string }, signal?: AbortSignal): AsyncGenerator<HarnessEvent, string> {
logger.info({ name, instruction: instruction.substring(0, 100) }, 'Streaming research subagent');
const gen = researchSubagent.streamEvents(context, prompt(name, instruction), signal);
let step: IteratorResult<HarnessEvent, string>;
while (!(step = await gen.next()).done) {
yield step.value;
}
const finalText = step.value;
const images = researchSubagent.getLastImages();
return JSON.stringify({ text: finalText, images });
}
const tool = 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({
name: z.string().describe('The name of the research script to create or update (e.g. "btc_ema_analysis"). Use the same name across calls to revise the same script rather than creating a new one.'),
instruction: z.string().describe('The research task or analysis to perform. Be specific about what data, indicators, timeframes, and output you want.'),
}),
func: async ({ name, instruction }: { name: string; instruction: string }): Promise<string> => {
logger.info({ name, instruction: instruction.substring(0, 100) }, 'Delegating to research subagent');
try {
const result = await researchSubagent.executeWithImages(context, prompt(name, instruction));
return JSON.stringify({ text: result.text, images: result.images });
} catch (error) {
logger.error({ error, errorMessage: (error as Error)?.message }, 'Research subagent failed');
throw error;
}
},
});
return Object.assign(tool, { streamFunc });
}

View File

@@ -0,0 +1,111 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';
import type { FastifyBaseLogger } from 'fastify';
import type { SpawnService } from '../../harness/spawn/spawn-service.js';
import type { MCPClientConnector } from '../../harness/mcp-client.js';
import type { WorkspaceManager } from '../../workspace/workspace-manager.js';
import type { MCPToolInfo } from '../mcp/mcp-tool-wrapper.js';
import type { HarnessEvent } from '../../harness/harness-events.js';
export interface SpawnToolConfig {
spawnService: SpawnService;
mcpClient: MCPClientConnector;
availableMCPTools: MCPToolInfo[];
workspaceManager?: WorkspaceManager;
logger: FastifyBaseLogger;
}
/**
* Creates the `spawn` tool for the main agent.
*
* The tool accepts an agent name and instruction and delegates to SpawnService,
* which runs an isolated subagent and returns only its final result.
*
* Implements the `streamFunc` protocol so the harness can forward intermediate
* subagent events (subagent_chunk, subagent_thinking, subagent_tool_call) to
* the WebSocket client during tool execution.
*/
export function createSpawnTool(config: SpawnToolConfig): DynamicStructuredTool & {
streamFunc: (args: { agent: string; instruction: string }, signal?: AbortSignal) => AsyncGenerator<HarnessEvent, string>;
} {
const { spawnService, mcpClient, availableMCPTools, workspaceManager, logger } = config;
async function* streamFunc(
{ agent, instruction }: { agent: string; instruction: string },
signal?: AbortSignal,
): AsyncGenerator<HarnessEvent, string> {
logger.info({ agent, instruction: instruction.substring(0, 100) }, 'spawn: streaming subagent');
const gen = spawnService.streamSpawn({
agentName: agent,
instruction,
mcpClient,
availableMCPTools,
workspaceManager,
signal,
});
let step = await gen.next();
while (!step.done) {
if (signal?.aborted) {
await gen.return('');
break;
}
yield step.value;
step = await gen.next();
}
return (step.value as string) ?? '';
}
const tool = new DynamicStructuredTool({
name: 'Spawn',
description: `Delegate a specialized task to a subagent and return its result.
Available agents:
- **research** — statistical analysis, data visualization, Python scripting, charting
- **indicator** — manage chart indicators: add, remove, modify, create custom indicators
- **strategy** — write PandasStrategy trading strategies, run backtests, activate paper trading
- **web-explore** — search the web, fetch pages, find academic papers
The subagent runs in isolation. Only its final answer is returned — intermediate steps do not appear in this context.
Examples:
- spawn({ agent: "research", instruction: "Plot BTC/USDT RSI over the last 2 years" })
- spawn({ agent: "indicator", instruction: "Add Bollinger Bands with std=1.5 to the chart" })
- spawn({ agent: "strategy", instruction: "Write an RSI mean-reversion strategy and backtest it" })
- spawn({ agent: "web-explore", instruction: "Find documentation on the Donchian Channel indicator" })`,
schema: z.object({
agent: z.enum(['research', 'indicator', 'strategy', 'web-explore']).describe(
'The specialized subagent to invoke'
),
instruction: z.string().describe(
'Detailed instruction for the subagent. Include all relevant context from the conversation.'
),
}),
func: async ({ agent, instruction }: { agent: string; instruction: string }): Promise<string> => {
logger.info({ agent, instruction: instruction.substring(0, 100) }, 'spawn: invoking subagent');
try {
const gen = spawnService.streamSpawn({
agentName: agent,
instruction,
mcpClient,
availableMCPTools,
workspaceManager,
});
// Drain the generator and collect the return value
let result = '';
let step = await gen.next();
while (!step.done) {
step = await gen.next();
}
result = step.value ?? '';
return result;
} catch (error) {
logger.error({ error, agent }, 'spawn: subagent failed');
throw error;
}
},
});
return Object.assign(tool, { streamFunc });
}

View File

@@ -1,66 +0,0 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';
import type { FastifyBaseLogger } from 'fastify';
import type { StrategySubagent } from '../../harness/subagents/strategy/index.js';
import type { SubagentContext } from '../../harness/subagents/base-subagent.js';
import type { HarnessEvent } from '../../harness/harness-events.js';
export interface StrategyAgentToolConfig {
strategySubagent: StrategySubagent;
context: SubagentContext;
logger: FastifyBaseLogger;
}
/**
* Creates a LangChain tool that delegates to the strategy subagent.
* Mirrors the pattern of indicator-agent.tool.ts.
*/
export function createStrategyAgentTool(config: StrategyAgentToolConfig): DynamicStructuredTool & { streamFunc: (args: { instruction: string }, signal?: AbortSignal) => AsyncGenerator<HarnessEvent, string> } {
const { strategySubagent, context, logger } = config;
async function* streamFunc({ instruction }: { instruction: string }, signal?: AbortSignal): AsyncGenerator<HarnessEvent, string> {
logger.info({ instruction: instruction.substring(0, 100) }, 'Streaming strategy subagent');
const gen = strategySubagent.streamEvents(context, instruction, signal);
let step: IteratorResult<HarnessEvent, string>;
while (!(step = await gen.next()).done) {
yield step.value;
}
return step.value;
}
const tool = new DynamicStructuredTool({
name: 'strategy',
description: `Delegate to the strategy subagent for all trading strategy tasks.
Use this tool for:
- Writing new PandasStrategy classes ("create a strategy that...")
- Editing or improving existing strategies
- Running backtests on a strategy
- Interpreting backtest results (Sharpe ratio, drawdown, trade list)
- Activating or deactivating strategies for paper trading
- Monitoring running strategy PnL and trade logs
- Checking which strategies already exist
ALWAYS use this tool for any request about trading strategies, backtesting, or strategy activation.
NEVER write strategy Python code or call backtest_strategy directly — delegate here instead.`,
schema: z.object({
instruction: z.string().describe(
'The strategy task to perform. Be specific: include the strategy name, ' +
'desired signals (e.g. RSI < 30 = buy), timeframe, and symbol if known. ' +
'For backtest requests include the date range and starting capital.'
),
}),
func: async ({ instruction }: { instruction: string }): Promise<string> => {
logger.info({ instruction: instruction.substring(0, 100) }, 'Delegating to strategy subagent');
try {
return await strategySubagent.execute(context, instruction);
} catch (error) {
logger.error({ error, errorMessage: (error as Error)?.message }, 'Strategy subagent failed');
throw error;
}
},
});
return Object.assign(tool, { streamFunc });
}

View File

@@ -21,7 +21,7 @@ export function createSymbolLookupTool(config: SymbolLookupToolConfig): DynamicS
const { symbolIndexService, logger } = config;
return new DynamicStructuredTool({
name: 'symbol_lookup',
name: 'SymbolLookup',
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:
@@ -34,7 +34,7 @@ Parameters:
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');
logger.debug({ mode, query, limit }, 'Executing SymbolLookup tool');
try {
if (mode === 'search') {

View File

@@ -1,67 +0,0 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';
import type { FastifyBaseLogger } from 'fastify';
import type { WebExploreSubagent } from '../../harness/subagents/web-explore/index.js';
import type { SubagentContext } from '../../harness/subagents/base-subagent.js';
import type { HarnessEvent } from '../../harness/harness-events.js';
export interface WebExploreAgentToolConfig {
webExploreSubagent: WebExploreSubagent;
context: SubagentContext;
logger: FastifyBaseLogger;
}
/**
* Creates a LangChain tool that delegates to the web-explore subagent.
* The subagent decides whether to use web search or arXiv based on the instruction.
*/
export function createWebExploreAgentTool(config: WebExploreAgentToolConfig): DynamicStructuredTool & { streamFunc: (args: { instruction: string }, signal?: AbortSignal) => AsyncGenerator<HarnessEvent, string> } {
const { webExploreSubagent, context, logger } = config;
async function* streamFunc({ instruction }: { instruction: string }, signal?: AbortSignal): AsyncGenerator<HarnessEvent, string> {
logger.info({ instruction: instruction.substring(0, 100) }, 'Streaming web-explore subagent');
const gen = webExploreSubagent.streamEvents(context, instruction, signal);
let step: IteratorResult<HarnessEvent, string>;
while (!(step = await gen.next()).done) {
yield step.value;
}
return step.value;
}
const tool = new DynamicStructuredTool({
name: 'web_explore',
description: `Search the EXTERNAL web or academic databases and return a summarized answer.
Use this tool ONLY for external, public information:
- Current events, news, or real-time information
- External documentation, tutorials, or how-to guides for third-party libraries/tools
- Academic papers, research findings, or scientific topics
- Any topic requiring external sources
NEVER use this tool for:
- Questions about the Dexorder platform itself (workspace tools, chartState, indicators, strategies)
- Internal API usage (workspace_patch, workspace_read, etc.) — consult the system prompt instead
- Anything that can be answered from the context already available
The subagent will search the web (or arXiv for academic queries), fetch relevant content, and return a markdown summary with cited sources.`,
schema: z.object({
instruction: z.string().describe(
'What to search for and summarize. Be specific — include the topic, what aspects matter, ' +
'and any context that helps narrow the search (e.g. "recent papers on momentum factor in equities" ' +
'or "how to configure rate limiting in Fastify").'
),
}),
func: async ({ instruction }: { instruction: string }): Promise<string> => {
logger.info({ instruction: instruction.substring(0, 100) }, 'Delegating to web-explore subagent');
try {
return await webExploreSubagent.execute(context, instruction);
} catch (error) {
logger.error({ error, errorMessage: (error as Error)?.message }, 'Web explore subagent failed');
throw error;
}
},
});
return Object.assign(tool, { streamFunc });
}

View File

@@ -18,14 +18,14 @@ export function createWebSearchTool(config: WebSearchToolConfig): DynamicStructu
const { apiKey, logger } = config;
return new DynamicStructuredTool({
name: 'web_search',
description: 'Search the web. Returns titles, URLs, and content summaries. Use this for general web searches. For academic/scientific papers, prefer arxiv_search instead.',
name: 'WebSearch',
description: 'Search the web. Returns titles, URLs, and content summaries. Use this for general web searches. For academic/scientific papers, prefer ArxivSearch instead.',
schema: z.object({
query: z.string().describe('The search query'),
max_results: z.number().optional().default(8).describe('Maximum number of results to return (default: 8)'),
}),
func: async ({ query, max_results }) => {
logger.debug({ query, max_results }, 'Executing web_search tool');
logger.debug({ query, max_results }, 'Executing WebSearch tool');
try {
const response = await fetch('https://api.tavily.com/search', {
@@ -57,7 +57,7 @@ export function createWebSearchTool(config: WebSearchToolConfig): DynamicStructu
return JSON.stringify({ query, results: items });
} catch (error) {
logger.error({ error, query, errorMessage: error instanceof Error ? error.message : String(error) }, 'web_search tool failed');
logger.error({ error, query, errorMessage: error instanceof Error ? error.message : String(error) }, 'WebSearch tool failed');
return JSON.stringify({ error: error instanceof Error ? error.message : String(error) });
}
},

View File

@@ -22,7 +22,7 @@ export interface AgentToolConfig {
/** Platform tool names to include */
platformTools: string[];
/** MCP tool patterns/names to include (supports wildcards like 'python_*') */
/** MCP tool patterns/names to include (supports wildcards like 'Python*') */
mcpTools: string[];
}
@@ -152,7 +152,7 @@ export class ToolRegistry {
let tool: DynamicStructuredTool | null = null;
switch (toolName) {
case 'symbol_lookup': {
case 'SymbolLookup': {
const symbolIndexService = this.resolveService(this.platformServices.symbolIndexService);
if (symbolIndexService) {
tool = createSymbolLookupTool({
@@ -160,12 +160,12 @@ export class ToolRegistry {
logger: this.logger,
});
} else {
this.logger.warn('SymbolIndexService not available for symbol_lookup tool');
this.logger.warn('SymbolIndexService not available for SymbolLookup tool');
}
break;
}
case 'get_chart_data': {
case 'GetChartData': {
const ohlcService = this.resolveService(this.platformServices.ohlcService);
// Use session workspace manager if provided, otherwise try global
const workspaceManager = sessionWorkspaceManager ||
@@ -179,27 +179,27 @@ export class ToolRegistry {
} else {
this.logger.warn(
{ hasOHLC: !!ohlcService, hasWorkspace: !!workspaceManager },
'OHLCService or WorkspaceManager not available for get_chart_data tool'
'OHLCService or WorkspaceManager not available for GetChartData tool'
);
}
break;
}
case 'web_search': {
case 'WebSearch': {
if (this.platformServices.tavilyApiKey) {
tool = createWebSearchTool({ apiKey: this.platformServices.tavilyApiKey, logger: this.logger });
} else {
this.logger.warn('TAVILY_API_KEY not configured — web_search tool unavailable');
this.logger.warn('TAVILY_API_KEY not configured — WebSearch tool unavailable');
}
break;
}
case 'fetch_page': {
case 'FetchPage': {
tool = createFetchPageTool({ logger: this.logger });
break;
}
case 'arxiv_search': {
case 'ArxivSearch': {
tool = createArxivSearchTool({ logger: this.logger });
break;
}
@@ -226,7 +226,7 @@ export class ToolRegistry {
/**
* Filter MCP tools based on patterns/names
* Supports wildcards like 'python_*' or exact names like 'execute_research'
* Supports wildcards like 'Python*' or exact names like 'ExecuteResearch'
*/
private filterMCPTools(availableTools: MCPToolInfo[], patterns: string[]): MCPToolInfo[] {
if (patterns.length === 0) {
@@ -245,7 +245,7 @@ export class ToolRegistry {
/**
* Check if a tool name matches a pattern
* Supports wildcards: 'python_*' matches 'python_write', 'python_read', etc.
* Supports wildcards: 'Python*' matches 'PythonWrite', 'PythonRead', etc.
*/
private matchesPattern(toolName: string, pattern: string): boolean {
if (pattern === toolName) {
@@ -264,6 +264,40 @@ export class ToolRegistry {
return false;
}
/**
* Resolve tools directly from explicit platform tool names and MCP patterns,
* without requiring a pre-registered agent config.
* Used by SpawnService to build tool lists from wiki frontmatter at spawn time.
*/
async resolveTools(
platformTools: string[],
mcpPatterns: string[],
mcpClient?: MCPClientConnector,
availableMCPTools?: MCPToolInfo[],
workspaceManager?: WorkspaceManager,
onImage?: (image: { data: string; mimeType: string }) => void,
onWorkspaceMutation?: (storeName: string, newState: unknown) => void
): Promise<DynamicStructuredTool[]> {
const tools: DynamicStructuredTool[] = [];
for (const toolName of platformTools) {
const tool = await this.getPlatformTool(toolName, workspaceManager);
if (tool) {
tools.push(tool);
} else {
this.logger.warn({ tool: toolName }, 'resolveTools: platform tool not found');
}
}
if (mcpClient && availableMCPTools && availableMCPTools.length > 0 && mcpPatterns.length > 0) {
const filteredMCPTools = this.filterMCPTools(availableMCPTools, mcpPatterns);
const mcpToolInstances = createMCPToolWrappers(filteredMCPTools, mcpClient, this.logger, onImage, onWorkspaceMutation);
tools.push(...mcpToolInstances);
}
return tools;
}
/**
* Get all registered agent names
*/

View File

@@ -8,9 +8,9 @@
* Container-side storage: /data/workspace/{store_name}.json
*
* MCP Tools used:
* - workspace_read(store_name) -> dict
* - workspace_write(store_name, data) -> None
* - workspace_patch(store_name, patch) -> dict (new state)
* - WorkspaceRead(store_name) -> dict
* - WorkspaceWrite(store_name, data) -> None
* - WorkspacePatch(store_name, patch) -> dict (new state)
*/
import type { FastifyBaseLogger } from 'fastify';
@@ -82,7 +82,7 @@ export class ContainerSync {
try {
this.logger.debug({ store: storeName }, 'Loading store from container');
const result = this.parseMcpResult(await this.mcpClient.callTool('workspace_read', {
const result = this.parseMcpResult(await this.mcpClient.callTool('WorkspaceRead', {
store_name: storeName,
})) as { exists: boolean; data?: unknown; error?: string };
@@ -118,7 +118,7 @@ export class ContainerSync {
try {
this.logger.debug({ store: storeName }, 'Saving store to container');
const result = this.parseMcpResult(await this.mcpClient.callTool('workspace_write', {
const result = this.parseMcpResult(await this.mcpClient.callTool('WorkspaceWrite', {
store_name: storeName,
data: state,
})) as { success: boolean; error?: string };
@@ -150,7 +150,7 @@ export class ContainerSync {
try {
this.logger.debug({ store: storeName, patchOps: patch.length }, 'Patching store in container');
const result = this.parseMcpResult(await this.mcpClient.callTool('workspace_patch', {
const result = this.parseMcpResult(await this.mcpClient.callTool('WorkspacePatch', {
store_name: storeName,
patch,
})) as { success: boolean; data?: unknown; error?: string };