bugfix; web tabs
This commit is contained in:
@@ -196,7 +196,7 @@ export class DuckDBClient {
|
||||
// Fallback: scan Parquet files written directly to conversations bucket
|
||||
if (this.conversationsBucket) {
|
||||
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 = `
|
||||
SELECT id, user_id, session_id, role, content, metadata, timestamp
|
||||
FROM read_parquet('${parquetPath}')
|
||||
@@ -709,15 +709,15 @@ export class DuckDBClient {
|
||||
if (!tablePath) {
|
||||
// Fallback: scan per-turn Parquet files written directly to S3
|
||||
if (this.conversationsBucket) {
|
||||
this.logger.debug({ userId, sessionId }, 'REST catalog miss, scanning blob Parquet files');
|
||||
const parquetPath = `s3://${this.conversationsBucket}/gateway/blobs/**/user_id=${userId}/${sessionId}_*.parquet`;
|
||||
this.logger.info({ userId, sessionId }, 'REST catalog miss, scanning blob Parquet files');
|
||||
const parquetPath = `s3://${this.conversationsBucket}/gateway/blobs/year=*/month=*/user_id=${userId}/${sessionId}_*.parquet`;
|
||||
const idClause = blobIds?.length
|
||||
? `WHERE id IN (${blobIds.map(id => `'${id.replace(/'/g, "''")}'`).join(', ')})`
|
||||
: '';
|
||||
try {
|
||||
return await this.query(`SELECT * FROM read_parquet('${parquetPath}') ${idClause} ORDER BY timestamp ASC`);
|
||||
} catch {
|
||||
// No blobs yet for this session
|
||||
} catch (err: any) {
|
||||
this.logger.debug({ err: err.message, userId, sessionId }, 'No blob Parquet files found for session');
|
||||
}
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -8,6 +8,8 @@ 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)
|
||||
@@ -73,44 +75,164 @@ export abstract class BaseSubagent {
|
||||
this.tools = tools || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize subagent: load system prompt and memory files
|
||||
*/
|
||||
async initialize(basePath: string): Promise<void> {
|
||||
this.logger.info({ subagent: this.config.name }, 'Initializing subagent');
|
||||
/** Per-subagent recursion limit for the LangGraph agent loop */
|
||||
protected abstract getRecursionLimit(): number;
|
||||
|
||||
// Load system prompt
|
||||
if (this.config.systemPromptFile) {
|
||||
const promptPath = join(basePath, this.config.systemPromptFile);
|
||||
this.systemPrompt = await this.loadFile(promptPath);
|
||||
}
|
||||
/** Fallback text returned when the agent produces no output */
|
||||
protected abstract getFallbackText(): string;
|
||||
|
||||
// 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'
|
||||
);
|
||||
/** Whether an MCP client is required; defaults true. Override to false for tool-only subagents. */
|
||||
protected requiresMCPClient(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
input: string
|
||||
): Promise<string>;
|
||||
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)
|
||||
@@ -169,17 +291,42 @@ export abstract class BaseSubagent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream typed HarnessEvents during execution.
|
||||
* Subclasses override this to emit subagent_chunk / subagent_tool_call events
|
||||
* using agent.stream() from LangGraph. Default falls back to execute().
|
||||
* Initialize subagent: load system prompt and memory files
|
||||
*/
|
||||
async *streamEvents(
|
||||
context: SubagentContext,
|
||||
input: string,
|
||||
_signal?: AbortSignal,
|
||||
): AsyncGenerator<HarnessEvent, string> {
|
||||
const result = await this.execute(context, input);
|
||||
return result;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 { SystemMessage } from '@langchain/core/messages';
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { MCPClientConnector } from '../../mcp-client.js';
|
||||
import type { HarnessEvent } from '../../harness-events.js';
|
||||
|
||||
/**
|
||||
* Indicator Subagent
|
||||
@@ -14,127 +11,10 @@ import type { HarnessEvent } from '../../harness-events.js';
|
||||
* - Read, add, modify, and remove indicators from the indicators store
|
||||
* - Create custom indicator scripts via python_* tools
|
||||
* - Validate indicators using the evaluate_indicator tool
|
||||
*
|
||||
* Simpler than ResearchSubagent — no image capture needed.
|
||||
*/
|
||||
export class IndicatorSubagent extends BaseSubagent {
|
||||
constructor(
|
||||
config: SubagentConfig,
|
||||
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;
|
||||
}
|
||||
protected getRecursionLimit() { return 25; }
|
||||
protected getFallbackText() { return 'Indicator update completed.'; }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,16 +27,8 @@ export async function createIndicatorSubagent(
|
||||
mcpClient?: MCPClientConnector,
|
||||
tools?: any[]
|
||||
): Promise<IndicatorSubagent> {
|
||||
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 config = await BaseSubagent.loadConfig(basePath);
|
||||
const subagent = new IndicatorSubagent(config, model, logger, mcpClient, tools);
|
||||
await subagent.initialize(basePath);
|
||||
|
||||
return subagent;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
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';
|
||||
@@ -47,6 +47,9 @@ export class ResearchSubagent extends BaseSubagent {
|
||||
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;
|
||||
}
|
||||
@@ -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.
|
||||
* This is the standard LangChain pattern for agents with tool access —
|
||||
* createReactAgent handles the tool calling loop automatically.
|
||||
* Wraps executeAgent to manage image capture state.
|
||||
*/
|
||||
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)
|
||||
this.imageCapture.length = 0;
|
||||
this.lastImages = [];
|
||||
|
||||
const customIndicatorsSection = await this.fetchCustomIndicatorsSection();
|
||||
|
||||
// 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'
|
||||
);
|
||||
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];
|
||||
|
||||
// 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(
|
||||
{ textLength: finalText.length, imageCount: this.lastImages.length },
|
||||
'Research subagent finished'
|
||||
@@ -225,73 +185,7 @@ export class ResearchSubagent extends BaseSubagent {
|
||||
// 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 customIndicatorsSection = await this.fetchCustomIndicatorsSection();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const finalText = yield* this.streamEventsCore(context, instruction, signal);
|
||||
|
||||
this.lastImages = [...this.imageCapture];
|
||||
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> {
|
||||
this.logger.info(
|
||||
@@ -351,21 +245,11 @@ export async function createResearchSubagent(
|
||||
tools?: any[],
|
||||
imageCapture?: Array<{data: string; mimeType: string}>
|
||||
): Promise<ResearchSubagent> {
|
||||
const { readFile } = await import('fs/promises');
|
||||
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 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;
|
||||
}
|
||||
|
||||
@@ -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 { SystemMessage } from '@langchain/core/messages';
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { MCPClientConnector } from '../../mcp-client.js';
|
||||
import type { HarnessEvent } from '../../harness-events.js';
|
||||
|
||||
/**
|
||||
* Strategy Subagent
|
||||
*
|
||||
* Specialized agent for writing PandasStrategy classes, running backtests,
|
||||
* and managing strategy activation/deactivation.
|
||||
*
|
||||
* Mirrors the pattern of IndicatorSubagent in indicator/index.ts.
|
||||
*/
|
||||
export class StrategySubagent extends BaseSubagent {
|
||||
constructor(
|
||||
config: SubagentConfig,
|
||||
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;
|
||||
}
|
||||
protected getRecursionLimit() { return 30; }
|
||||
protected getFallbackText() { return 'Strategy task completed.'; }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,16 +24,8 @@ export async function createStrategySubagent(
|
||||
mcpClient?: MCPClientConnector,
|
||||
tools?: any[]
|
||||
): Promise<StrategySubagent> {
|
||||
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 config = await BaseSubagent.loadConfig(basePath);
|
||||
const subagent = new StrategySubagent(config, model, logger, mcpClient, tools);
|
||||
await subagent.initialize(basePath);
|
||||
|
||||
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 { SystemMessage } from '@langchain/core/messages';
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { HarnessEvent } from '../../harness-events.js';
|
||||
|
||||
/**
|
||||
* Web Explore Subagent
|
||||
@@ -24,95 +21,9 @@ export class WebExploreSubagent extends BaseSubagent {
|
||||
super(config, model, logger, undefined, tools);
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
'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;
|
||||
}
|
||||
protected getRecursionLimit() { return 15; }
|
||||
protected getFallbackText() { return 'No results found.'; }
|
||||
protected requiresMCPClient() { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,16 +35,8 @@ export async function createWebExploreSubagent(
|
||||
basePath: string,
|
||||
tools?: any[]
|
||||
): Promise<WebExploreSubagent> {
|
||||
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 config = await BaseSubagent.loadConfig(basePath);
|
||||
const subagent = new WebExploreSubagent(config, model, logger, tools);
|
||||
await subagent.initialize(basePath);
|
||||
|
||||
return subagent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user