bugfix; web tabs
This commit is contained in:
4
doc/test_prompt.md
Normal file
4
doc/test_prompt.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
what conclusions can you make by analyzing historical data on ETH price direction changes near market session overlaps and market sessions changes on monday and tuesday?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ export class DuckDBClient {
|
|||||||
// Fallback: scan Parquet files written directly to conversations bucket
|
// Fallback: scan Parquet files written directly to conversations bucket
|
||||||
if (this.conversationsBucket) {
|
if (this.conversationsBucket) {
|
||||||
this.logger.debug({ userId, sessionId }, 'REST catalog miss, scanning Parquet cold storage');
|
this.logger.debug({ userId, sessionId }, 'REST catalog miss, scanning Parquet cold storage');
|
||||||
const parquetPath = `s3://${this.conversationsBucket}/gateway/conversations/**/user_id=${userId}/${sessionId}.parquet`;
|
const parquetPath = `s3://${this.conversationsBucket}/gateway/conversations/year=*/month=*/user_id=${userId}/${sessionId}.parquet`;
|
||||||
const fallbackSql = `
|
const fallbackSql = `
|
||||||
SELECT id, user_id, session_id, role, content, metadata, timestamp
|
SELECT id, user_id, session_id, role, content, metadata, timestamp
|
||||||
FROM read_parquet('${parquetPath}')
|
FROM read_parquet('${parquetPath}')
|
||||||
@@ -709,15 +709,15 @@ export class DuckDBClient {
|
|||||||
if (!tablePath) {
|
if (!tablePath) {
|
||||||
// Fallback: scan per-turn Parquet files written directly to S3
|
// Fallback: scan per-turn Parquet files written directly to S3
|
||||||
if (this.conversationsBucket) {
|
if (this.conversationsBucket) {
|
||||||
this.logger.debug({ userId, sessionId }, 'REST catalog miss, scanning blob Parquet files');
|
this.logger.info({ userId, sessionId }, 'REST catalog miss, scanning blob Parquet files');
|
||||||
const parquetPath = `s3://${this.conversationsBucket}/gateway/blobs/**/user_id=${userId}/${sessionId}_*.parquet`;
|
const parquetPath = `s3://${this.conversationsBucket}/gateway/blobs/year=*/month=*/user_id=${userId}/${sessionId}_*.parquet`;
|
||||||
const idClause = blobIds?.length
|
const idClause = blobIds?.length
|
||||||
? `WHERE id IN (${blobIds.map(id => `'${id.replace(/'/g, "''")}'`).join(', ')})`
|
? `WHERE id IN (${blobIds.map(id => `'${id.replace(/'/g, "''")}'`).join(', ')})`
|
||||||
: '';
|
: '';
|
||||||
try {
|
try {
|
||||||
return await this.query(`SELECT * FROM read_parquet('${parquetPath}') ${idClause} ORDER BY timestamp ASC`);
|
return await this.query(`SELECT * FROM read_parquet('${parquetPath}') ${idClause} ORDER BY timestamp ASC`);
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
// No blobs yet for this session
|
this.logger.debug({ err: err.message, userId, sessionId }, 'No blob Parquet files found for session');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type { DynamicStructuredTool } from '@langchain/core/tools';
|
|||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import type { HarnessEvent, SubagentChunkEvent, SubagentThinkingEvent } from '../harness-events.js';
|
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)
|
* Subagent configuration (loaded from config.yaml)
|
||||||
@@ -73,44 +75,164 @@ export abstract class BaseSubagent {
|
|||||||
this.tools = tools || [];
|
this.tools = tools || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Per-subagent recursion limit for the LangGraph agent loop */
|
||||||
* Initialize subagent: load system prompt and memory files
|
protected abstract getRecursionLimit(): number;
|
||||||
*/
|
|
||||||
async initialize(basePath: string): Promise<void> {
|
|
||||||
this.logger.info({ subagent: this.config.name }, 'Initializing subagent');
|
|
||||||
|
|
||||||
// Load system prompt
|
/** Fallback text returned when the agent produces no output */
|
||||||
if (this.config.systemPromptFile) {
|
protected abstract getFallbackText(): string;
|
||||||
const promptPath = join(basePath, this.config.systemPromptFile);
|
|
||||||
this.systemPrompt = await this.loadFile(promptPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load memory files
|
/** Whether an MCP client is required; defaults true. Override to false for tool-only subagents. */
|
||||||
for (const memoryFile of this.config.memoryFiles) {
|
protected requiresMCPClient(): boolean {
|
||||||
const memoryPath = join(basePath, 'memory', memoryFile);
|
return true;
|
||||||
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'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute subagent with given input
|
* Build the system message and final human message for agent invocation.
|
||||||
|
* Subclasses may override to augment the system message (e.g. injecting dynamic context).
|
||||||
*/
|
*/
|
||||||
abstract execute(
|
protected async buildSystemMessage(
|
||||||
context: SubagentContext,
|
context: SubagentContext,
|
||||||
input: string
|
instruction: string
|
||||||
): Promise<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)
|
* Stream execution (optional, default to non-streaming)
|
||||||
@@ -169,17 +291,42 @@ export abstract class BaseSubagent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream typed HarnessEvents during execution.
|
* Initialize subagent: load system prompt and memory files
|
||||||
* Subclasses override this to emit subagent_chunk / subagent_tool_call events
|
|
||||||
* using agent.stream() from LangGraph. Default falls back to execute().
|
|
||||||
*/
|
*/
|
||||||
async *streamEvents(
|
async initialize(basePath: string): Promise<void> {
|
||||||
context: SubagentContext,
|
this.logger.info({ subagent: this.config.name }, 'Initializing subagent');
|
||||||
input: string,
|
|
||||||
_signal?: AbortSignal,
|
// Load system prompt
|
||||||
): AsyncGenerator<HarnessEvent, string> {
|
if (this.config.systemPromptFile) {
|
||||||
const result = await this.execute(context, input);
|
const promptPath = join(basePath, this.config.systemPromptFile);
|
||||||
return result;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { BaseSubagent, type SubagentConfig, type SubagentContext } from '../base-subagent.js';
|
import { BaseSubagent } from '../base-subagent.js';
|
||||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||||
import { SystemMessage } from '@langchain/core/messages';
|
|
||||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
import type { MCPClientConnector } from '../../mcp-client.js';
|
import type { MCPClientConnector } from '../../mcp-client.js';
|
||||||
import type { HarnessEvent } from '../../harness-events.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicator Subagent
|
* Indicator Subagent
|
||||||
@@ -14,127 +11,10 @@ import type { HarnessEvent } from '../../harness-events.js';
|
|||||||
* - Read, add, modify, and remove indicators from the indicators store
|
* - Read, add, modify, and remove indicators from the indicators store
|
||||||
* - Create custom indicator scripts via python_* tools
|
* - Create custom indicator scripts via python_* tools
|
||||||
* - Validate indicators using the evaluate_indicator tool
|
* - Validate indicators using the evaluate_indicator tool
|
||||||
*
|
|
||||||
* Simpler than ResearchSubagent — no image capture needed.
|
|
||||||
*/
|
*/
|
||||||
export class IndicatorSubagent extends BaseSubagent {
|
export class IndicatorSubagent extends BaseSubagent {
|
||||||
constructor(
|
protected getRecursionLimit() { return 25; }
|
||||||
config: SubagentConfig,
|
protected getFallbackText() { return 'Indicator update completed.'; }
|
||||||
model: BaseChatModel,
|
|
||||||
logger: FastifyBaseLogger,
|
|
||||||
mcpClient?: MCPClientConnector,
|
|
||||||
tools?: any[]
|
|
||||||
) {
|
|
||||||
super(config, model, logger, mcpClient, tools);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute indicator request using LangGraph's createReactAgent.
|
|
||||||
*/
|
|
||||||
async execute(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),
|
|
||||||
},
|
|
||||||
'Indicator subagent starting'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!this.hasMCPClient()) {
|
|
||||||
throw new Error('MCP client not available for indicator subagent');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.tools.length === 0) {
|
|
||||||
this.logger.warn('Indicator subagent has no tools — cannot read or patch workspace');
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialMessages = this.buildMessages(context, instruction);
|
|
||||||
const systemMessage = initialMessages[0];
|
|
||||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
|
||||||
|
|
||||||
const agent = createReactAgent({
|
|
||||||
llm: this.model,
|
|
||||||
tools: this.tools,
|
|
||||||
prompt: systemMessage as SystemMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await agent.invoke(
|
|
||||||
{ messages: [humanMessage] },
|
|
||||||
{ recursionLimit: 25 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const allMessages: any[] = result.messages ?? [];
|
|
||||||
|
|
||||||
this.logger.info(
|
|
||||||
{ messageCount: allMessages.length },
|
|
||||||
'Indicator 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))
|
|
||||||
: 'Indicator update completed.';
|
|
||||||
|
|
||||||
this.logger.info({ textLength: finalText.length }, 'Indicator subagent finished');
|
|
||||||
|
|
||||||
return finalText;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 indicator subagent');
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialMessages = this.buildMessages(context, instruction);
|
|
||||||
const systemMessage = initialMessages[0];
|
|
||||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
|
||||||
|
|
||||||
const agent = createReactAgent({
|
|
||||||
llm: this.model,
|
|
||||||
tools: this.tools,
|
|
||||||
prompt: systemMessage as SystemMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stream = agent.stream(
|
|
||||||
{ messages: [humanMessage] },
|
|
||||||
{ streamMode: ['messages', 'updates'], recursionLimit: 25, signal }
|
|
||||||
);
|
|
||||||
|
|
||||||
let finalText = '';
|
|
||||||
|
|
||||||
for await (const [mode, data] of await stream) {
|
|
||||||
if (signal?.aborted) break;
|
|
||||||
if (mode === 'messages') {
|
|
||||||
for (const chunk of IndicatorSubagent.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 = IndicatorSubagent.extractFinalText(msg);
|
|
||||||
if (content) finalText = content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.info({ textLength: finalText.length }, 'streamEvents finished');
|
|
||||||
return finalText;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,16 +27,8 @@ export async function createIndicatorSubagent(
|
|||||||
mcpClient?: MCPClientConnector,
|
mcpClient?: MCPClientConnector,
|
||||||
tools?: any[]
|
tools?: any[]
|
||||||
): Promise<IndicatorSubagent> {
|
): Promise<IndicatorSubagent> {
|
||||||
const { readFile } = await import('fs/promises');
|
const config = await BaseSubagent.loadConfig(basePath);
|
||||||
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 IndicatorSubagent(config, model, logger, mcpClient, tools);
|
const subagent = new IndicatorSubagent(config, model, logger, mcpClient, tools);
|
||||||
await subagent.initialize(basePath);
|
await subagent.initialize(basePath);
|
||||||
|
|
||||||
return subagent;
|
return subagent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseSubagent, type SubagentConfig, type SubagentContext } from '../base-subagent.js';
|
import { BaseSubagent, type SubagentConfig, type SubagentContext } from '../base-subagent.js';
|
||||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||||
import { SystemMessage } from '@langchain/core/messages';
|
import { SystemMessage } from '@langchain/core/messages';
|
||||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
import type { BaseMessage } from '@langchain/core/messages';
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
import type { MCPClientConnector } from '../../mcp-client.js';
|
import type { MCPClientConnector } from '../../mcp-client.js';
|
||||||
import type { HarnessEvent } from '../../harness-events.js';
|
import type { HarnessEvent } from '../../harness-events.js';
|
||||||
@@ -47,6 +47,9 @@ export class ResearchSubagent extends BaseSubagent {
|
|||||||
super(config, model, logger, mcpClient, tools);
|
super(config, model, logger, mcpClient, tools);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getRecursionLimit() { return 40; }
|
||||||
|
protected getFallbackText() { return 'Research completed.'; }
|
||||||
|
|
||||||
setImageCapture(capture: Array<{data: string; mimeType: string}>): void {
|
setImageCapture(capture: Array<{data: string; mimeType: string}>): void {
|
||||||
this.imageCapture = capture;
|
this.imageCapture = capture;
|
||||||
}
|
}
|
||||||
@@ -103,81 +106,38 @@ export class ResearchSubagent extends BaseSubagent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Execute research request using LangGraph's createReactAgent.
|
||||||
* This is the standard LangChain pattern for agents with tool access —
|
* Wraps executeAgent to manage image capture state.
|
||||||
* createReactAgent handles the tool calling loop automatically.
|
|
||||||
*/
|
*/
|
||||||
async execute(context: SubagentContext, instruction: string): Promise<string> {
|
async execute(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),
|
|
||||||
},
|
|
||||||
'Research subagent starting'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!this.hasMCPClient()) {
|
|
||||||
throw new Error('MCP client not available for research subagent');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.tools.length === 0) {
|
|
||||||
this.logger.warn('Research subagent has no tools — cannot write or execute scripts');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear previous images (in-place so tool wrappers keep the same array reference)
|
// Clear previous images (in-place so tool wrappers keep the same array reference)
|
||||||
this.imageCapture.length = 0;
|
this.imageCapture.length = 0;
|
||||||
this.lastImages = [];
|
this.lastImages = [];
|
||||||
|
|
||||||
const customIndicatorsSection = await this.fetchCustomIndicatorsSection();
|
const finalText = await this.executeAgent(context, instruction);
|
||||||
|
|
||||||
// Build system prompt (with memory context appended)
|
|
||||||
const initialMessages = this.buildMessages(context, instruction);
|
|
||||||
// buildMessages returns [SystemMessage, ...history, HumanMessage]
|
|
||||||
// Extract system content for createReactAgent's prompt parameter
|
|
||||||
let systemMessage = initialMessages[0] as SystemMessage;
|
|
||||||
if (customIndicatorsSection) {
|
|
||||||
const base = typeof systemMessage.content === 'string' ? systemMessage.content : JSON.stringify(systemMessage.content);
|
|
||||||
systemMessage = new SystemMessage(base + customIndicatorsSection);
|
|
||||||
}
|
|
||||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
|
||||||
|
|
||||||
// createReactAgent is the standard LangChain/LangGraph pattern for tool-using agents.
|
|
||||||
// It manages the tool calling loop, message accumulation, and termination automatically.
|
|
||||||
const agent = createReactAgent({
|
|
||||||
llm: this.model,
|
|
||||||
tools: this.tools,
|
|
||||||
prompt: systemMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await agent.invoke(
|
|
||||||
{ messages: [humanMessage] },
|
|
||||||
{ recursionLimit: 40 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// The final message in the graph output is the agent's last AIMessage
|
|
||||||
const allMessages: any[] = result.messages ?? [];
|
|
||||||
|
|
||||||
this.logger.info(
|
|
||||||
{ messageCount: allMessages.length },
|
|
||||||
'Research subagent graph completed'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Images were captured in real-time by the MCP tool wrappers into this.imageCapture
|
// Images were captured in real-time by the MCP tool wrappers into this.imageCapture
|
||||||
this.lastImages = [...this.imageCapture];
|
this.lastImages = [...this.imageCapture];
|
||||||
|
|
||||||
// Return the final AI response
|
|
||||||
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))
|
|
||||||
: 'Research completed.';
|
|
||||||
|
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{ textLength: finalText.length, imageCount: this.lastImages.length },
|
{ textLength: finalText.length, imageCount: this.lastImages.length },
|
||||||
'Research subagent finished'
|
'Research subagent finished'
|
||||||
@@ -225,73 +185,7 @@ export class ResearchSubagent extends BaseSubagent {
|
|||||||
// the first `updates` event fires (after the LLM finishes its first response).
|
// 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...' };
|
yield { type: 'subagent_tool_call', agentName: this.config.name, toolName: 'Thinking...', label: 'Thinking...' };
|
||||||
|
|
||||||
const customIndicatorsSection = await this.fetchCustomIndicatorsSection();
|
const finalText = yield* this.streamEventsCore(context, instruction, signal);
|
||||||
|
|
||||||
const initialMessages = this.buildMessages(context, instruction);
|
|
||||||
let systemMessage = initialMessages[0] as SystemMessage;
|
|
||||||
if (customIndicatorsSection) {
|
|
||||||
const base = typeof systemMessage.content === 'string' ? systemMessage.content : JSON.stringify(systemMessage.content);
|
|
||||||
systemMessage = new SystemMessage(base + customIndicatorsSection);
|
|
||||||
}
|
|
||||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
|
||||||
|
|
||||||
const agent = createReactAgent({
|
|
||||||
llm: this.model,
|
|
||||||
tools: this.tools,
|
|
||||||
prompt: systemMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.debug(
|
|
||||||
{ toolCount: this.tools.length, toolNames: this.tools.map(t => t.name) },
|
|
||||||
'Research subagent: starting stream with tools'
|
|
||||||
);
|
|
||||||
|
|
||||||
const systemChars = typeof systemMessage.content === 'string'
|
|
||||||
? systemMessage.content.length
|
|
||||||
: JSON.stringify(systemMessage.content).length;
|
|
||||||
const humanChars = typeof humanMessage.content === 'string'
|
|
||||||
? humanMessage.content.length
|
|
||||||
: JSON.stringify(humanMessage.content).length;
|
|
||||||
this.logger.info(
|
|
||||||
{ systemChars, humanChars, approxInputKB: Math.round((systemChars + humanChars) / 1024) },
|
|
||||||
'Research subagent: input context size'
|
|
||||||
);
|
|
||||||
|
|
||||||
const stream = agent.stream(
|
|
||||||
{ messages: [humanMessage] },
|
|
||||||
{ streamMode: ['messages', 'updates'], recursionLimit: 40, signal }
|
|
||||||
);
|
|
||||||
|
|
||||||
let finalText = '';
|
|
||||||
let updateCount = 0;
|
|
||||||
|
|
||||||
for await (const [mode, data] of await stream) {
|
|
||||||
if (signal?.aborted) break;
|
|
||||||
if (mode === 'messages') {
|
|
||||||
// Real-time token streaming from the LLM — data is [BaseMessage, metadata]
|
|
||||||
for (const chunk of ResearchSubagent.extractStreamChunks(data, this.config.name)) {
|
|
||||||
yield chunk;
|
|
||||||
}
|
|
||||||
} else if (mode === 'updates') {
|
|
||||||
updateCount++;
|
|
||||||
const updateKeys = Object.keys(data as any);
|
|
||||||
this.logger.debug({ updateCount, updateKeys }, 'Research subagent: graph update');
|
|
||||||
// Agent node fired — yield tool call decisions before tools run
|
|
||||||
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 {
|
|
||||||
// Capture final text for return value (already streamed via messages above)
|
|
||||||
const content = ResearchSubagent.extractFinalText(msg);
|
|
||||||
if (content) finalText = content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lastImages = [...this.imageCapture];
|
this.lastImages = [...this.imageCapture];
|
||||||
if (!finalText) {
|
if (!finalText) {
|
||||||
@@ -309,7 +203,7 @@ export class ResearchSubagent extends BaseSubagent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream research execution
|
* Stream research execution (raw model streaming, no agent loop)
|
||||||
*/
|
*/
|
||||||
async *stream(context: SubagentContext, instruction: string): AsyncGenerator<string> {
|
async *stream(context: SubagentContext, instruction: string): AsyncGenerator<string> {
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
@@ -351,21 +245,11 @@ export async function createResearchSubagent(
|
|||||||
tools?: any[],
|
tools?: any[],
|
||||||
imageCapture?: Array<{data: string; mimeType: string}>
|
imageCapture?: Array<{data: string; mimeType: string}>
|
||||||
): Promise<ResearchSubagent> {
|
): Promise<ResearchSubagent> {
|
||||||
const { readFile } = await import('fs/promises');
|
const config = await BaseSubagent.loadConfig(basePath);
|
||||||
const { join } = await import('path');
|
|
||||||
const yaml = await import('js-yaml');
|
|
||||||
|
|
||||||
// Load config
|
|
||||||
const configPath = join(basePath, 'config.yaml');
|
|
||||||
const configContent = await readFile(configPath, 'utf-8');
|
|
||||||
const config = yaml.load(configContent) as SubagentConfig;
|
|
||||||
|
|
||||||
// Create and initialize subagent
|
|
||||||
const subagent = new ResearchSubagent(config, model, logger, mcpClient, tools);
|
const subagent = new ResearchSubagent(config, model, logger, mcpClient, tools);
|
||||||
if (imageCapture !== undefined) {
|
if (imageCapture !== undefined) {
|
||||||
subagent.setImageCapture(imageCapture);
|
subagent.setImageCapture(imageCapture);
|
||||||
}
|
}
|
||||||
await subagent.initialize(basePath);
|
await subagent.initialize(basePath);
|
||||||
|
|
||||||
return subagent;
|
return subagent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +1,17 @@
|
|||||||
import { BaseSubagent, type SubagentConfig, type SubagentContext } from '../base-subagent.js';
|
import { BaseSubagent } from '../base-subagent.js';
|
||||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||||
import { SystemMessage } from '@langchain/core/messages';
|
|
||||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
import type { MCPClientConnector } from '../../mcp-client.js';
|
import type { MCPClientConnector } from '../../mcp-client.js';
|
||||||
import type { HarnessEvent } from '../../harness-events.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strategy Subagent
|
* Strategy Subagent
|
||||||
*
|
*
|
||||||
* Specialized agent for writing PandasStrategy classes, running backtests,
|
* Specialized agent for writing PandasStrategy classes, running backtests,
|
||||||
* and managing strategy activation/deactivation.
|
* and managing strategy activation/deactivation.
|
||||||
*
|
|
||||||
* Mirrors the pattern of IndicatorSubagent in indicator/index.ts.
|
|
||||||
*/
|
*/
|
||||||
export class StrategySubagent extends BaseSubagent {
|
export class StrategySubagent extends BaseSubagent {
|
||||||
constructor(
|
protected getRecursionLimit() { return 30; }
|
||||||
config: SubagentConfig,
|
protected getFallbackText() { return 'Strategy task completed.'; }
|
||||||
model: BaseChatModel,
|
|
||||||
logger: FastifyBaseLogger,
|
|
||||||
mcpClient?: MCPClientConnector,
|
|
||||||
tools?: any[]
|
|
||||||
) {
|
|
||||||
super(config, model, logger, mcpClient, tools);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a strategy request using LangGraph's createReactAgent.
|
|
||||||
*/
|
|
||||||
async execute(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),
|
|
||||||
},
|
|
||||||
'Strategy subagent starting'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!this.hasMCPClient()) {
|
|
||||||
throw new Error('MCP client not available for strategy subagent');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.tools.length === 0) {
|
|
||||||
this.logger.warn('Strategy subagent has no tools');
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialMessages = this.buildMessages(context, instruction);
|
|
||||||
const systemMessage = initialMessages[0];
|
|
||||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
|
||||||
|
|
||||||
const agent = createReactAgent({
|
|
||||||
llm: this.model,
|
|
||||||
tools: this.tools,
|
|
||||||
prompt: systemMessage as SystemMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await agent.invoke(
|
|
||||||
{ messages: [humanMessage] },
|
|
||||||
{ recursionLimit: 30 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const allMessages: any[] = result.messages ?? [];
|
|
||||||
|
|
||||||
this.logger.info(
|
|
||||||
{ messageCount: allMessages.length },
|
|
||||||
'Strategy 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))
|
|
||||||
: 'Strategy task completed.';
|
|
||||||
|
|
||||||
this.logger.info({ textLength: finalText.length }, 'Strategy subagent finished');
|
|
||||||
|
|
||||||
return finalText;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 strategy subagent');
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialMessages = this.buildMessages(context, instruction);
|
|
||||||
const systemMessage = initialMessages[0];
|
|
||||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
|
||||||
|
|
||||||
const agent = createReactAgent({
|
|
||||||
llm: this.model,
|
|
||||||
tools: this.tools,
|
|
||||||
prompt: systemMessage as SystemMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stream = agent.stream(
|
|
||||||
{ messages: [humanMessage] },
|
|
||||||
{ streamMode: ['messages', 'updates'], recursionLimit: 30, signal }
|
|
||||||
);
|
|
||||||
|
|
||||||
let finalText = '';
|
|
||||||
|
|
||||||
for await (const [mode, data] of await stream) {
|
|
||||||
if (signal?.aborted) break;
|
|
||||||
if (mode === 'messages') {
|
|
||||||
for (const chunk of StrategySubagent.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 = StrategySubagent.extractFinalText(msg);
|
|
||||||
if (content) finalText = content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.info({ textLength: finalText.length }, 'streamEvents finished');
|
|
||||||
return finalText;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,16 +24,8 @@ export async function createStrategySubagent(
|
|||||||
mcpClient?: MCPClientConnector,
|
mcpClient?: MCPClientConnector,
|
||||||
tools?: any[]
|
tools?: any[]
|
||||||
): Promise<StrategySubagent> {
|
): Promise<StrategySubagent> {
|
||||||
const { readFile } = await import('fs/promises');
|
const config = await BaseSubagent.loadConfig(basePath);
|
||||||
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 StrategySubagent(config, model, logger, mcpClient, tools);
|
const subagent = new StrategySubagent(config, model, logger, mcpClient, tools);
|
||||||
await subagent.initialize(basePath);
|
await subagent.initialize(basePath);
|
||||||
|
|
||||||
return subagent;
|
return subagent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { BaseSubagent, type SubagentConfig, type SubagentContext } from '../base-subagent.js';
|
import { BaseSubagent, type SubagentConfig } from '../base-subagent.js';
|
||||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||||
import { SystemMessage } from '@langchain/core/messages';
|
|
||||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
import type { HarnessEvent } from '../../harness-events.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web Explore Subagent
|
* Web Explore Subagent
|
||||||
@@ -24,95 +21,9 @@ export class WebExploreSubagent extends BaseSubagent {
|
|||||||
super(config, model, logger, undefined, tools);
|
super(config, model, logger, undefined, tools);
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(context: SubagentContext, instruction: string): Promise<string> {
|
protected getRecursionLimit() { return 15; }
|
||||||
this.logger.info(
|
protected getFallbackText() { return 'No results found.'; }
|
||||||
{
|
protected requiresMCPClient() { return false; }
|
||||||
subagent: this.getName(),
|
|
||||||
userId: context.userContext.userId,
|
|
||||||
instruction: instruction.substring(0, 200),
|
|
||||||
toolCount: this.tools.length,
|
|
||||||
toolNames: this.tools.map(t => t.name),
|
|
||||||
},
|
|
||||||
'Web explore subagent starting'
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialMessages = this.buildMessages(context, instruction);
|
|
||||||
const systemMessage = initialMessages[0];
|
|
||||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
|
||||||
|
|
||||||
const agent = createReactAgent({
|
|
||||||
llm: this.model,
|
|
||||||
tools: this.tools,
|
|
||||||
prompt: systemMessage as SystemMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await agent.invoke(
|
|
||||||
{ messages: [humanMessage] },
|
|
||||||
{ recursionLimit: 15 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const allMessages: any[] = result.messages ?? [];
|
|
||||||
|
|
||||||
this.logger.info({ messageCount: allMessages.length }, 'Web explore 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))
|
|
||||||
: 'No results found.';
|
|
||||||
|
|
||||||
this.logger.info({ textLength: finalText.length }, 'Web explore subagent finished');
|
|
||||||
|
|
||||||
return finalText;
|
|
||||||
}
|
|
||||||
|
|
||||||
async *streamEvents(context: SubagentContext, instruction: string, signal?: AbortSignal): AsyncGenerator<HarnessEvent, string> {
|
|
||||||
this.logger.info({ subagent: this.getName() }, 'streamEvents starting');
|
|
||||||
|
|
||||||
const initialMessages = this.buildMessages(context, instruction);
|
|
||||||
const systemMessage = initialMessages[0];
|
|
||||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
|
||||||
|
|
||||||
const agent = createReactAgent({
|
|
||||||
llm: this.model,
|
|
||||||
tools: this.tools,
|
|
||||||
prompt: systemMessage as SystemMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stream = agent.stream(
|
|
||||||
{ messages: [humanMessage] },
|
|
||||||
{ streamMode: ['messages', 'updates'], recursionLimit: 15, signal }
|
|
||||||
);
|
|
||||||
|
|
||||||
let finalText = '';
|
|
||||||
|
|
||||||
for await (const [mode, data] of await stream) {
|
|
||||||
if (signal?.aborted) break;
|
|
||||||
if (mode === 'messages') {
|
|
||||||
for (const chunk of WebExploreSubagent.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 = WebExploreSubagent.extractFinalText(msg);
|
|
||||||
if (content) finalText = content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.info({ textLength: finalText.length }, 'streamEvents finished');
|
|
||||||
return finalText;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,16 +35,8 @@ export async function createWebExploreSubagent(
|
|||||||
basePath: string,
|
basePath: string,
|
||||||
tools?: any[]
|
tools?: any[]
|
||||||
): Promise<WebExploreSubagent> {
|
): Promise<WebExploreSubagent> {
|
||||||
const { readFile } = await import('fs/promises');
|
const config = await BaseSubagent.loadConfig(basePath);
|
||||||
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 WebExploreSubagent(config, model, logger, tools);
|
const subagent = new WebExploreSubagent(config, model, logger, tools);
|
||||||
await subagent.initialize(basePath);
|
await subagent.initialize(basePath);
|
||||||
|
|
||||||
return subagent;
|
return subagent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SplitterPanel from 'primevue/splitterpanel'
|
|||||||
import ChartView from './components/ChartView.vue'
|
import ChartView from './components/ChartView.vue'
|
||||||
import ChatPanel from './components/ChatPanel.vue'
|
import ChatPanel from './components/ChatPanel.vue'
|
||||||
import LoginScreen from './components/LoginScreen.vue'
|
import LoginScreen from './components/LoginScreen.vue'
|
||||||
|
import BottomTray from './components/BottomTray.vue'
|
||||||
import { useChartStore } from './stores/chart'
|
import { useChartStore } from './stores/chart'
|
||||||
import { useShapeStore } from './stores/shapes'
|
import { useShapeStore } from './stores/shapes'
|
||||||
import { useIndicatorStore } from './stores/indicators'
|
import { useIndicatorStore } from './stores/indicators'
|
||||||
@@ -137,14 +138,19 @@ onBeforeUnmount(() => {
|
|||||||
:error-message="authError"
|
:error-message="authError"
|
||||||
@authenticate="handleAuthenticate"
|
@authenticate="handleAuthenticate"
|
||||||
/>
|
/>
|
||||||
<Splitter v-else-if="!isMobile" class="main-splitter">
|
<div v-else-if="!isMobile" class="desktop-layout">
|
||||||
<SplitterPanel :size="62" :minSize="40" class="chart-panel">
|
<div class="top-area">
|
||||||
<ChartView />
|
<Splitter class="main-splitter">
|
||||||
</SplitterPanel>
|
<SplitterPanel :size="62" :minSize="40" class="chart-panel">
|
||||||
<SplitterPanel :size="38" :minSize="20" class="chat-panel">
|
<ChartView />
|
||||||
<ChatPanel />
|
</SplitterPanel>
|
||||||
</SplitterPanel>
|
<SplitterPanel :size="38" :minSize="20" class="chat-panel">
|
||||||
</Splitter>
|
<ChatPanel />
|
||||||
|
</SplitterPanel>
|
||||||
|
</Splitter>
|
||||||
|
</div>
|
||||||
|
<BottomTray />
|
||||||
|
</div>
|
||||||
<div v-else class="mobile-layout">
|
<div v-else class="mobile-layout">
|
||||||
<ChatPanel />
|
<ChatPanel />
|
||||||
</div>
|
</div>
|
||||||
@@ -165,8 +171,21 @@ onBeforeUnmount(() => {
|
|||||||
background: #0f0f0f !important;
|
background: #0f0f0f !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-area {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.main-splitter {
|
.main-splitter {
|
||||||
height: 100vh !important;
|
height: 100% !important;
|
||||||
background: #0f0f0f !important;
|
background: #0f0f0f !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +219,7 @@ onBeforeUnmount(() => {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
cursor: col-resize;
|
cursor: auto;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
279
web/src/components/BottomTray.vue
Normal file
279
web/src/components/BottomTray.vue
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onBeforeUnmount, type Component } from 'vue'
|
||||||
|
import Tabs from 'primevue/tabs'
|
||||||
|
import TabList from 'primevue/tablist'
|
||||||
|
import Tab from 'primevue/tab'
|
||||||
|
import TabPanels from 'primevue/tabpanels'
|
||||||
|
import TabPanel from 'primevue/tabpanel'
|
||||||
|
import OrdersTab from './tabs/OrdersTab.vue'
|
||||||
|
import PlaceholderTab from './tabs/PlaceholderTab.vue'
|
||||||
|
|
||||||
|
interface TempTab {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
component: Component
|
||||||
|
props?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLLAPSED_HEIGHT = 34
|
||||||
|
const DEFAULT_EXPANDED = 260
|
||||||
|
const MIN_EXPANDED = 80
|
||||||
|
|
||||||
|
const isExpanded = ref(false)
|
||||||
|
const expandedHeight = ref(DEFAULT_EXPANDED)
|
||||||
|
const activeTab = ref('orders')
|
||||||
|
const tempTabs = ref<TempTab[]>([])
|
||||||
|
|
||||||
|
const trayStyle = computed(() => ({
|
||||||
|
height: isExpanded.value ? `${expandedHeight.value}px` : `${COLLAPSED_HEIGHT}px`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function onTabClick(tabId: string) {
|
||||||
|
if (!isExpanded.value) {
|
||||||
|
activeTab.value = tabId
|
||||||
|
isExpanded.value = true
|
||||||
|
} else if (activeTab.value === tabId) {
|
||||||
|
isExpanded.value = false
|
||||||
|
} else {
|
||||||
|
activeTab.value = tabId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTab(tabId: string) {
|
||||||
|
const idx = tempTabs.value.findIndex(t => t.id === tabId)
|
||||||
|
if (idx === -1) return
|
||||||
|
tempTabs.value.splice(idx, 1)
|
||||||
|
if (activeTab.value === tabId) {
|
||||||
|
activeTab.value = tempTabs.value[idx - 1]?.id ?? tempTabs.value[0]?.id ?? 'orders'
|
||||||
|
if (tempTabs.value.length === 0) isExpanded.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize handle drag
|
||||||
|
let resizeStartY = 0
|
||||||
|
let resizeStartHeight = 0
|
||||||
|
|
||||||
|
function startResize(e: MouseEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
resizeStartY = e.clientY
|
||||||
|
resizeStartHeight = expandedHeight.value
|
||||||
|
document.addEventListener('mousemove', onResizeMove)
|
||||||
|
document.addEventListener('mouseup', stopResize)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizeMove(e: MouseEvent) {
|
||||||
|
const delta = resizeStartY - e.clientY // dragging up increases height
|
||||||
|
expandedHeight.value = Math.max(MIN_EXPANDED, resizeStartHeight + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopResize() {
|
||||||
|
document.removeEventListener('mousemove', onResizeMove)
|
||||||
|
document.removeEventListener('mouseup', stopResize)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('mousemove', onResizeMove)
|
||||||
|
document.removeEventListener('mouseup', stopResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
openTab(id: string, label: string, component: Component, props?: Record<string, any>) {
|
||||||
|
const existing = tempTabs.value.find(t => t.id === id)
|
||||||
|
if (!existing) {
|
||||||
|
tempTabs.value.push({ id, label, component, props })
|
||||||
|
}
|
||||||
|
activeTab.value = id
|
||||||
|
isExpanded.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bottom-tray" :style="trayStyle">
|
||||||
|
<div v-if="isExpanded" class="tray-resize-handle" @mousedown="startResize" />
|
||||||
|
<Tabs :value="activeTab" class="tray-tabs">
|
||||||
|
<TabList class="tray-tab-list">
|
||||||
|
<Tab value="orders" @click="onTabClick('orders')">Orders</Tab>
|
||||||
|
<Tab value="strategies" @click="onTabClick('strategies')">Strategies</Tab>
|
||||||
|
<Tab value="positions" @click="onTabClick('positions')">Positions</Tab>
|
||||||
|
<Tab
|
||||||
|
v-for="tab in tempTabs"
|
||||||
|
:key="tab.id"
|
||||||
|
:value="tab.id"
|
||||||
|
class="tab-closeable"
|
||||||
|
@click="onTabClick(tab.id)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
<button class="tab-close-btn" @click.stop="closeTab(tab.id)">×</button>
|
||||||
|
</Tab>
|
||||||
|
<div class="tray-spacer" />
|
||||||
|
<button v-if="isExpanded" class="tray-close-btn" @click="isExpanded = false">✕</button>
|
||||||
|
</TabList>
|
||||||
|
<TabPanels v-if="isExpanded" class="tray-panels">
|
||||||
|
<TabPanel value="orders" class="tray-panel"><OrdersTab /></TabPanel>
|
||||||
|
<TabPanel value="strategies" class="tray-panel"><PlaceholderTab label="Strategies" /></TabPanel>
|
||||||
|
<TabPanel value="positions" class="tray-panel"><PlaceholderTab label="Positions" /></TabPanel>
|
||||||
|
<TabPanel
|
||||||
|
v-for="tab in tempTabs"
|
||||||
|
:key="tab.id"
|
||||||
|
:value="tab.id"
|
||||||
|
class="tray-panel"
|
||||||
|
>
|
||||||
|
<component :is="tab.component" v-bind="tab.props" />
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bottom-tray {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: #0f0f0f;
|
||||||
|
border-top: 1px solid #2e2e2e;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: height 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-resize-handle {
|
||||||
|
height: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #2e2e2e;
|
||||||
|
cursor: row-resize;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-resize-handle:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-tabs {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-tab-list {
|
||||||
|
height: 34px !important;
|
||||||
|
min-height: 34px !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
background: #141414 !important;
|
||||||
|
border-bottom: 1px solid #2e2e2e !important;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override PrimeVue Tab default styles */
|
||||||
|
.tray-tab-list :deep(.p-tab) {
|
||||||
|
padding: 0 12px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
line-height: 28px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: #888 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-tab-list :deep(.p-tab:hover) {
|
||||||
|
color: #dbdbdb !important;
|
||||||
|
background: #1e1e1e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-tab-list :deep(.p-tab-active) {
|
||||||
|
color: #dbdbdb !important;
|
||||||
|
background: #1e1e1e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the PrimeVue active indicator bar */
|
||||||
|
.tray-tab-list :deep(.p-tablist-active-bar) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close-btn:hover {
|
||||||
|
color: #dbdbdb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-close-btn:hover {
|
||||||
|
color: #dbdbdb;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-panels {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0 !important;
|
||||||
|
background: #0f0f0f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-panels :deep(.p-tabpanels) {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 !important;
|
||||||
|
background: #0f0f0f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-panel {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-panel :deep(.p-tabpanel-content) {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 !important;
|
||||||
|
background: #0f0f0f !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
69
web/src/components/tabs/OrdersTab.vue
Normal file
69
web/src/components/tabs/OrdersTab.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DataTable from 'primevue/datatable'
|
||||||
|
import Column from 'primevue/column'
|
||||||
|
import { useOrderStore } from '../../stores/orders'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
|
const ordersStore = useOrderStore()
|
||||||
|
const { orders } = storeToRefs(ordersStore)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DataTable
|
||||||
|
:value="orders"
|
||||||
|
scrollable
|
||||||
|
scrollHeight="flex"
|
||||||
|
size="small"
|
||||||
|
class="orders-table"
|
||||||
|
:empty-message="'No orders'"
|
||||||
|
>
|
||||||
|
<Column field="tokenIn" header="Token In" style="min-width: 90px" />
|
||||||
|
<Column field="tokenOut" header="Token Out" style="min-width: 90px" />
|
||||||
|
<Column field="route.exchange" header="Exchange" style="min-width: 100px" />
|
||||||
|
<Column field="route.fee" header="Fee" style="min-width: 70px" />
|
||||||
|
<Column field="amount" header="Amount" style="min-width: 100px" />
|
||||||
|
<Column field="minFillAmount" header="Min Fill" style="min-width: 100px" />
|
||||||
|
<Column field="amountIsInput" header="Amt Is Input" style="min-width: 100px">
|
||||||
|
<template #body="{ data }">{{ data.amountIsInput ? 'Yes' : 'No' }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="conditionalOrder" header="Condition" style="min-width: 100px" />
|
||||||
|
</DataTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.orders-table {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-table :deep(.p-datatable-header-cell) {
|
||||||
|
background: #1a1a1a !important;
|
||||||
|
color: #aaa !important;
|
||||||
|
border-color: #2e2e2e !important;
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-table :deep(.p-datatable-row-cell) {
|
||||||
|
background: #0f0f0f !important;
|
||||||
|
color: #dbdbdb !important;
|
||||||
|
border-color: #1e1e1e !important;
|
||||||
|
padding: 3px 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-table :deep(tr:hover .p-datatable-row-cell) {
|
||||||
|
background: #1a1a1a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders-table :deep(.p-datatable-empty-message td) {
|
||||||
|
background: #0f0f0f !important;
|
||||||
|
color: #555 !important;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
20
web/src/components/tabs/PlaceholderTab.vue
Normal file
20
web/src/components/tabs/PlaceholderTab.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ label: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="placeholder-tab">
|
||||||
|
<span>{{ label }} — no data</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.placeholder-tab {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user