data pipeline refactor and fix
This commit is contained in:
@@ -4,6 +4,7 @@ import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { License } from '../types/user.js';
|
||||
import { ChannelType } from '../types/user.js';
|
||||
import type { ConversationStore } from './memory/conversation-store.js';
|
||||
import type { BlobStore } from './memory/blob-store.js';
|
||||
import type { InboundMessage, OutboundMessage } from '../types/messages.js';
|
||||
import { MCPClientConnector } from './mcp-client.js';
|
||||
import { LLMProviderFactory, type ProviderConfig } from '../llm/provider.js';
|
||||
@@ -14,13 +15,16 @@ import type { ChannelAdapter, PathTriggerContext } from '../workspace/index.js';
|
||||
import type { ResearchSubagent } from './subagents/research/index.js';
|
||||
import type { IndicatorSubagent } from './subagents/indicator/index.js';
|
||||
import type { WebExploreSubagent } from './subagents/web-explore/index.js';
|
||||
import type { StrategySubagent } from './subagents/strategy/index.js';
|
||||
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import { getToolRegistry } from '../tools/tool-registry.js';
|
||||
import type { MCPToolInfo } from '../tools/mcp/mcp-tool-wrapper.js';
|
||||
import { createResearchAgentTool } from '../tools/platform/research-agent.tool.js';
|
||||
import { createIndicatorAgentTool } from '../tools/platform/indicator-agent.tool.js';
|
||||
import { createWebExploreAgentTool } from '../tools/platform/web-explore-agent.tool.js';
|
||||
import { createStrategyAgentTool } from '../tools/platform/strategy-agent.tool.js';
|
||||
import { createUserContext } from './memory/session-context.js';
|
||||
import type { HarnessEvent } from './harness-events.js';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -54,10 +58,12 @@ export type HarnessFactory = (sessionConfig: HarnessSessionConfig) => AgentHarne
|
||||
export interface AgentHarnessConfig extends HarnessSessionConfig {
|
||||
providerConfig: ProviderConfig;
|
||||
conversationStore?: ConversationStore;
|
||||
blobStore?: BlobStore;
|
||||
historyLimit: number;
|
||||
researchSubagent?: ResearchSubagent;
|
||||
indicatorSubagent?: IndicatorSubagent;
|
||||
webExploreSubagent?: WebExploreSubagent;
|
||||
strategySubagent?: StrategySubagent;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +93,8 @@ export class AgentHarness {
|
||||
private conversationStore?: ConversationStore;
|
||||
private indicatorSubagent?: IndicatorSubagent;
|
||||
private webExploreSubagent?: WebExploreSubagent;
|
||||
private strategySubagent?: StrategySubagent;
|
||||
private blobStore?: BlobStore;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
constructor(config: AgentHarnessConfig) {
|
||||
@@ -96,10 +104,12 @@ export class AgentHarness {
|
||||
this.researchSubagent = config.researchSubagent;
|
||||
this.indicatorSubagent = config.indicatorSubagent;
|
||||
this.webExploreSubagent = config.webExploreSubagent;
|
||||
this.strategySubagent = config.strategySubagent;
|
||||
|
||||
this.modelFactory = new LLMProviderFactory(config.providerConfig, config.logger);
|
||||
this.modelRouter = new ModelRouter(this.modelFactory, config.logger);
|
||||
this.conversationStore = config.conversationStore;
|
||||
this.blobStore = config.blobStore;
|
||||
|
||||
this.mcpClient = new MCPClientConnector({
|
||||
userId: config.userId,
|
||||
@@ -419,17 +429,75 @@ export class AgentHarness {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize strategy subagent
|
||||
*/
|
||||
private async initializeStrategySubagent(): Promise<void> {
|
||||
if (this.strategySubagent) {
|
||||
this.config.logger.debug('Strategy subagent already provided');
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.logger.debug('Creating strategy subagent for session');
|
||||
|
||||
try {
|
||||
const { createStrategySubagent } = await import('./subagents/strategy/index.js');
|
||||
|
||||
const { model } = await this.modelRouter.route(
|
||||
'trading strategy writing and backtesting',
|
||||
this.config.license,
|
||||
RoutingStrategy.COMPLEXITY,
|
||||
this.config.userId
|
||||
);
|
||||
|
||||
const toolRegistry = getToolRegistry();
|
||||
const strategyTools = await toolRegistry.getToolsForAgent(
|
||||
'strategy',
|
||||
this.mcpClient,
|
||||
this.availableMCPTools,
|
||||
this.workspaceManager,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const strategySubagentPath = join(__dirname, 'subagents', 'strategy');
|
||||
this.config.logger.debug({ strategySubagentPath }, 'Using strategy subagent path');
|
||||
|
||||
this.strategySubagent = await createStrategySubagent(
|
||||
model,
|
||||
this.config.logger,
|
||||
strategySubagentPath,
|
||||
this.mcpClient,
|
||||
strategyTools
|
||||
);
|
||||
|
||||
this.config.logger.info(
|
||||
{
|
||||
toolCount: strategyTools.length,
|
||||
toolNames: strategyTools.map(t => t.name),
|
||||
},
|
||||
'Strategy subagent created successfully'
|
||||
);
|
||||
} catch (error) {
|
||||
this.config.logger.error(
|
||||
{ error, errorMessage: (error as Error).message, stack: (error as Error).stack },
|
||||
'Failed to create strategy subagent'
|
||||
);
|
||||
// Don't throw — strategy subagent is optional
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute model with tool calling loop
|
||||
* Handles multi-turn tool calls until the model produces a final text response
|
||||
*/
|
||||
private async executeWithToolCalling(
|
||||
private async *executeWithToolCalling(
|
||||
model: any,
|
||||
messages: BaseMessage[],
|
||||
tools: DynamicStructuredTool[],
|
||||
maxIterations: number = 2,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
): AsyncGenerator<HarnessEvent> {
|
||||
this.config.logger.info(
|
||||
{ toolCount: tools.length, maxIterations },
|
||||
'Starting tool calling loop'
|
||||
@@ -437,6 +505,8 @@ export class AgentHarness {
|
||||
|
||||
const messagesCopy = [...messages];
|
||||
let iterations = 0;
|
||||
// Track last char of last yielded text chunk to detect missing spaces between tokens
|
||||
let lastChunkTail = '';
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
if (signal?.aborted) break;
|
||||
@@ -455,15 +525,24 @@ export class AgentHarness {
|
||||
try {
|
||||
const stream = await model.stream(messagesCopy, { signal });
|
||||
for await (const chunk of stream) {
|
||||
const contents: string[] = [];
|
||||
if (typeof chunk.content === 'string' && chunk.content.length > 0) {
|
||||
this.channelAdapter?.sendChunk(chunk.content);
|
||||
contents.push(chunk.content);
|
||||
} else if (Array.isArray(chunk.content)) {
|
||||
for (const block of chunk.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
this.channelAdapter?.sendChunk(block.text);
|
||||
}
|
||||
if (block.type === 'text' && block.text) contents.push(block.text);
|
||||
}
|
||||
}
|
||||
for (const content of contents) {
|
||||
// DeepInfra/GLM streams tokens without leading spaces; inject one when
|
||||
// both the tail of the previous chunk and the head of this chunk are
|
||||
// word characters (\w), which would otherwise merge two words.
|
||||
if (lastChunkTail && /\w/.test(lastChunkTail) && /\w/.test(content[0])) {
|
||||
yield { type: 'chunk', content: ' ' };
|
||||
}
|
||||
lastChunkTail = content[content.length - 1];
|
||||
yield { type: 'chunk', content };
|
||||
}
|
||||
response = response ? response.concat(chunk) : chunk;
|
||||
}
|
||||
} catch (invokeError: any) {
|
||||
@@ -486,6 +565,8 @@ export class AgentHarness {
|
||||
contentLength: typeof response.content === 'string' ? response.content.length : 0,
|
||||
hasToolCalls: !!response.tool_calls,
|
||||
toolCallCount: response.tool_calls?.length || 0,
|
||||
usageMetadata: (response as any).usage_metadata,
|
||||
finishReason: (response as any).response_metadata?.finish_reason,
|
||||
},
|
||||
'Model response received'
|
||||
);
|
||||
@@ -508,7 +589,8 @@ export class AgentHarness {
|
||||
{ finalContentLength: finalContent.length, iterations },
|
||||
'Tool calling loop complete - no more tool calls'
|
||||
);
|
||||
return finalContent;
|
||||
yield { type: 'done', content: finalContent };
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.logger.info(
|
||||
@@ -540,11 +622,32 @@ export class AgentHarness {
|
||||
}
|
||||
|
||||
try {
|
||||
this.channelAdapter?.sendToolCall?.(toolCall.name, this.getToolLabel(toolCall.name));
|
||||
const result = await tool.func(toolCall.args);
|
||||
yield { type: 'tool_call', toolName: toolCall.name, label: this.getToolLabel(toolCall.name) };
|
||||
|
||||
// Process result to extract images and send them via channel adapter
|
||||
const processedResult = this.processToolResult(result, toolCall.name);
|
||||
// Use streamFunc when available (subagent tools) to forward intermediate events inline
|
||||
let result: string;
|
||||
const streamFunc = (tool as any).streamFunc as ((args: any, signal?: AbortSignal) => AsyncGenerator<import('./harness-events.js').HarnessEvent, string>) | undefined;
|
||||
if (streamFunc) {
|
||||
const gen = streamFunc(toolCall.args, signal);
|
||||
let next = await gen.next();
|
||||
while (!next.done) {
|
||||
if (signal?.aborted) {
|
||||
gen.return?.('');
|
||||
break;
|
||||
}
|
||||
yield next.value;
|
||||
next = await gen.next();
|
||||
}
|
||||
result = next.done ? next.value : '';
|
||||
} else {
|
||||
result = await tool.func(toolCall.args);
|
||||
}
|
||||
|
||||
// Extract images from result and yield them; get text-only version for LLM
|
||||
const { cleanedResult: processedResult, images } = this.extractImagesFromToolResult(result, toolCall.name);
|
||||
for (const img of images) {
|
||||
yield { type: 'image', data: img.data, mimeType: img.mimeType, caption: img.caption };
|
||||
}
|
||||
|
||||
this.config.logger.debug(
|
||||
{
|
||||
@@ -567,6 +670,12 @@ export class AgentHarness {
|
||||
'Tool execution completed'
|
||||
);
|
||||
} catch (error) {
|
||||
// Clean stop — abort signal fired during tool execution; exit without error message
|
||||
if (signal?.aborted || (error as Error)?.name === 'AbortError') {
|
||||
this.config.logger.info({ tool: toolCall.name }, 'Tool execution aborted by stop signal');
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.logger.error(
|
||||
{
|
||||
error,
|
||||
@@ -578,6 +687,8 @@ export class AgentHarness {
|
||||
'Tool execution failed'
|
||||
);
|
||||
|
||||
yield { type: 'error' as const, source: toolCall.name, fatal: false };
|
||||
|
||||
messagesCopy.push(
|
||||
new ToolMessage({
|
||||
content: `Error: ${error}`,
|
||||
@@ -586,11 +697,15 @@ export class AgentHarness {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// After all tool calls complete, emit a space separator before the next LLM streaming pass
|
||||
yield { type: 'chunk', content: ' ' };
|
||||
lastChunkTail = ' ';
|
||||
}
|
||||
|
||||
// Max iterations reached - return what we have
|
||||
// Max iterations reached - yield done with apology
|
||||
this.config.logger.warn('Max tool calling iterations reached');
|
||||
return 'I apologize, but I encountered an issue processing your request. Please try rephrasing your question.';
|
||||
yield { type: 'done', content: 'I apologize, but I encountered an issue processing your request. Please try rephrasing your question.' };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -617,162 +732,222 @@ export class AgentHarness {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message from user
|
||||
* Stream events for an incoming user message.
|
||||
* Yields typed HarnessEvents (chunk, tool_call, image, done) and saves the
|
||||
* conversation to the store once the done event has been emitted.
|
||||
*/
|
||||
async handleMessage(message: InboundMessage): Promise<OutboundMessage> {
|
||||
async *streamMessage(message: InboundMessage): AsyncGenerator<HarnessEvent> {
|
||||
this.config.logger.info(
|
||||
{ messageId: message.messageId, userId: message.userId, content: message.content.substring(0, 100) },
|
||||
'Processing user message'
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. Build system prompt from template
|
||||
this.config.logger.debug('Building system prompt');
|
||||
const systemPrompt = await this.buildSystemPrompt();
|
||||
this.config.logger.debug({ systemPromptLength: systemPrompt.length }, 'System prompt built');
|
||||
// 1. Build system prompt from template
|
||||
this.config.logger.debug('Building system prompt');
|
||||
const systemPrompt = await this.buildSystemPrompt();
|
||||
this.config.logger.debug({ systemPromptLength: systemPrompt.length }, 'System prompt built');
|
||||
|
||||
// 2. Load recent conversation history
|
||||
const channelKey = this.config.channelType ?? ChannelType.WEBSOCKET;
|
||||
let storedMessages = this.conversationStore
|
||||
? await this.conversationStore.getRecentMessages(
|
||||
this.config.userId, this.config.sessionId, this.config.historyLimit, channelKey
|
||||
)
|
||||
: [];
|
||||
|
||||
// First turn: seed conversation history with current workspace state
|
||||
if (storedMessages.length === 0 && this.workspaceManager && this.conversationStore) {
|
||||
const workspaceJSON = this.workspaceManager.serializeState();
|
||||
const content = `[Workspace State]\n\`\`\`json\n${workspaceJSON}\n\`\`\``;
|
||||
await this.conversationStore.saveMessage(
|
||||
this.config.userId, this.config.sessionId,
|
||||
'workspace', content, { isWorkspaceContext: true }, channelKey
|
||||
);
|
||||
storedMessages = await this.conversationStore.getRecentMessages(
|
||||
// 2. Load recent conversation history
|
||||
const channelKey = this.config.channelType ?? ChannelType.WEBSOCKET;
|
||||
let storedMessages = this.conversationStore
|
||||
? await this.conversationStore.getRecentMessages(
|
||||
this.config.userId, this.config.sessionId, this.config.historyLimit, channelKey
|
||||
);
|
||||
}
|
||||
)
|
||||
: [];
|
||||
|
||||
const history = this.conversationStore
|
||||
? this.conversationStore.toLangChainMessages(storedMessages)
|
||||
: [];
|
||||
this.config.logger.debug({ historyLength: history.length }, 'Conversation history loaded');
|
||||
|
||||
// 4. Get the configured model
|
||||
this.config.logger.debug('Routing to model');
|
||||
const { model, middleware } = await this.modelRouter.route(
|
||||
message.content,
|
||||
this.config.license,
|
||||
RoutingStrategy.COMPLEXITY,
|
||||
this.config.userId
|
||||
// First turn: seed conversation history with current workspace state
|
||||
if (storedMessages.length === 0 && this.workspaceManager && this.conversationStore) {
|
||||
const workspaceJSON = this.workspaceManager.serializeState();
|
||||
const content = `[Workspace State]\n\`\`\`json\n${workspaceJSON}\n\`\`\``;
|
||||
await this.conversationStore.saveMessage(
|
||||
this.config.userId, this.config.sessionId,
|
||||
'workspace', content, { isWorkspaceContext: true }, channelKey
|
||||
);
|
||||
this.middleware = middleware;
|
||||
this.config.logger.info({ modelName: model.constructor.name }, 'Model selected');
|
||||
|
||||
// 5. Build LangChain messages
|
||||
const langchainMessages = this.buildLangChainMessages(systemPrompt, history, message.content);
|
||||
this.config.logger.debug({ messageCount: langchainMessages.length }, 'LangChain messages built');
|
||||
|
||||
// 6. Get tools for main agent from registry
|
||||
const toolRegistry = getToolRegistry();
|
||||
const tools = await toolRegistry.getToolsForAgent(
|
||||
'main',
|
||||
this.mcpClient,
|
||||
this.availableMCPTools,
|
||||
this.workspaceManager // Pass session workspace manager
|
||||
storedMessages = await this.conversationStore.getRecentMessages(
|
||||
this.config.userId, this.config.sessionId, this.config.historyLimit, channelKey
|
||||
);
|
||||
}
|
||||
|
||||
// Build shared subagent context
|
||||
const subagentContext = {
|
||||
userContext: createUserContext({
|
||||
userId: this.config.userId,
|
||||
sessionId: this.config.sessionId,
|
||||
license: this.config.license,
|
||||
channelType: this.config.channelType ?? ChannelType.WEBSOCKET,
|
||||
channelUserId: this.config.channelUserId ?? this.config.userId,
|
||||
}),
|
||||
};
|
||||
const history = this.conversationStore
|
||||
? this.conversationStore.toLangChainMessages(storedMessages)
|
||||
: [];
|
||||
this.config.logger.debug({ historyLength: history.length }, 'Conversation history loaded');
|
||||
|
||||
// Add research subagent as a tool if available
|
||||
if (this.researchSubagent) {
|
||||
tools.push(createResearchAgentTool({
|
||||
researchSubagent: this.researchSubagent,
|
||||
context: subagentContext,
|
||||
logger: this.config.logger,
|
||||
}));
|
||||
}
|
||||
// 4. Get the configured model
|
||||
this.config.logger.debug('Routing to model');
|
||||
const { model, middleware } = await this.modelRouter.route(
|
||||
message.content,
|
||||
this.config.license,
|
||||
RoutingStrategy.COMPLEXITY,
|
||||
this.config.userId
|
||||
);
|
||||
this.middleware = middleware;
|
||||
this.config.logger.info({ modelName: model.constructor.name }, 'Model selected');
|
||||
|
||||
// Add indicator subagent as a tool if available
|
||||
if (this.indicatorSubagent) {
|
||||
tools.push(createIndicatorAgentTool({
|
||||
indicatorSubagent: this.indicatorSubagent,
|
||||
context: subagentContext,
|
||||
logger: this.config.logger,
|
||||
}));
|
||||
}
|
||||
// 5. Build LangChain messages
|
||||
const langchainMessages = this.buildLangChainMessages(systemPrompt, history, message.content);
|
||||
this.config.logger.debug({ messageCount: langchainMessages.length }, 'LangChain messages built');
|
||||
|
||||
// Add web explore subagent as a tool if available
|
||||
if (this.webExploreSubagent) {
|
||||
tools.push(createWebExploreAgentTool({
|
||||
webExploreSubagent: this.webExploreSubagent,
|
||||
context: subagentContext,
|
||||
logger: this.config.logger,
|
||||
}));
|
||||
}
|
||||
// 6. Get tools for main agent from registry
|
||||
const toolRegistry = getToolRegistry();
|
||||
const tools = await toolRegistry.getToolsForAgent(
|
||||
'main',
|
||||
this.mcpClient,
|
||||
this.availableMCPTools,
|
||||
this.workspaceManager
|
||||
);
|
||||
|
||||
// Build shared subagent context
|
||||
const subagentContext = {
|
||||
userContext: createUserContext({
|
||||
userId: this.config.userId,
|
||||
sessionId: this.config.sessionId,
|
||||
license: this.config.license,
|
||||
channelType: this.config.channelType ?? ChannelType.WEBSOCKET,
|
||||
channelUserId: this.config.channelUserId ?? this.config.userId,
|
||||
}),
|
||||
};
|
||||
|
||||
if (this.researchSubagent) {
|
||||
tools.push(createResearchAgentTool({
|
||||
researchSubagent: this.researchSubagent,
|
||||
context: subagentContext,
|
||||
logger: this.config.logger,
|
||||
}));
|
||||
}
|
||||
|
||||
if (this.indicatorSubagent) {
|
||||
tools.push(createIndicatorAgentTool({
|
||||
indicatorSubagent: this.indicatorSubagent,
|
||||
context: subagentContext,
|
||||
logger: this.config.logger,
|
||||
}));
|
||||
}
|
||||
|
||||
if (this.webExploreSubagent) {
|
||||
tools.push(createWebExploreAgentTool({
|
||||
webExploreSubagent: this.webExploreSubagent,
|
||||
context: subagentContext,
|
||||
logger: this.config.logger,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!this.strategySubagent) {
|
||||
await this.initializeStrategySubagent();
|
||||
}
|
||||
if (this.strategySubagent) {
|
||||
tools.push(createStrategyAgentTool({
|
||||
strategySubagent: this.strategySubagent,
|
||||
context: subagentContext,
|
||||
logger: this.config.logger,
|
||||
}));
|
||||
}
|
||||
|
||||
this.config.logger.info(
|
||||
{ toolCount: tools.length, toolNames: tools.map(t => t.name) },
|
||||
'Tools loaded for main agent'
|
||||
);
|
||||
|
||||
// Apply middleware (e.g. Anthropic prompt caching)
|
||||
const processedMessages = this.middleware
|
||||
? this.middleware.processMessages(langchainMessages, tools)
|
||||
: langchainMessages;
|
||||
|
||||
// 7. Bind tools to model
|
||||
const modelWithTools = tools.length > 0 && model.bindTools ? model.bindTools(tools) : model;
|
||||
|
||||
if (tools.length > 0) {
|
||||
this.config.logger.info(
|
||||
{
|
||||
toolCount: tools.length,
|
||||
toolNames: tools.map(t => t.name),
|
||||
},
|
||||
'Tools loaded for main agent'
|
||||
{ modelType: modelWithTools.constructor.name, toolsBound: tools.length > 0 && !!model.bindTools },
|
||||
'Model bound with tools'
|
||||
);
|
||||
}
|
||||
|
||||
// Apply middleware (e.g. Anthropic prompt caching)
|
||||
const processedMessages = this.middleware
|
||||
? this.middleware.processMessages(langchainMessages, tools)
|
||||
: langchainMessages;
|
||||
|
||||
// 7. Bind tools to model
|
||||
const modelWithTools = tools.length > 0 && model.bindTools ? model.bindTools(tools) : model;
|
||||
|
||||
if (tools.length > 0) {
|
||||
this.config.logger.info(
|
||||
{ modelType: modelWithTools.constructor.name, toolsBound: tools.length > 0 && !!model.bindTools },
|
||||
'Model bound with tools'
|
||||
);
|
||||
// 8. Stream tool calling loop and save conversation on completion
|
||||
this.config.logger.info('Invoking LLM with tool support');
|
||||
this.abortController = new AbortController();
|
||||
let finalContent = '';
|
||||
const collectedImages: Array<{ data: string; mimeType: string; caption?: string }> = [];
|
||||
try {
|
||||
for await (const event of this.executeWithToolCalling(modelWithTools, processedMessages, tools, 10, this.abortController.signal)) {
|
||||
if (event.type === 'done') {
|
||||
finalContent = event.content;
|
||||
this.config.logger.info({ responseLength: finalContent.length }, 'LLM response received');
|
||||
} else if (event.type === 'image') {
|
||||
collectedImages.push({ data: event.data, mimeType: event.mimeType, caption: event.caption });
|
||||
}
|
||||
yield event;
|
||||
}
|
||||
|
||||
// 8. Call LLM with tool calling loop
|
||||
this.config.logger.info('Invoking LLM with tool support');
|
||||
this.abortController = new AbortController();
|
||||
const assistantMessage = await this.executeWithToolCalling(modelWithTools, processedMessages, tools, 10, this.abortController.signal);
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError') {
|
||||
this.config.logger.info('Agent harness interrupted by stop signal');
|
||||
} else {
|
||||
this.config.logger.error({ error }, 'Fatal error in agent harness');
|
||||
yield { type: 'error' as const, source: 'agent harness', fatal: true };
|
||||
}
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
if (finalContent && this.conversationStore) {
|
||||
// Write blobs to S3 and capture their IDs for message metadata
|
||||
let blobRefs: Array<{ id: string; mimeType: string; caption?: string }> = [];
|
||||
if (collectedImages.length > 0 && this.blobStore) {
|
||||
const assistantMsgId = `${this.config.userId}:${this.config.sessionId}:${Date.now()}`;
|
||||
const blobIds = await this.blobStore.writeBlobs(
|
||||
this.config.userId, this.config.sessionId, assistantMsgId,
|
||||
collectedImages.map(img => ({ blobType: 'image' as const, mimeType: img.mimeType, data: img.data, caption: img.caption }))
|
||||
);
|
||||
blobRefs = blobIds.map((id, i) => ({ id, mimeType: collectedImages[i].mimeType, caption: collectedImages[i].caption }));
|
||||
}
|
||||
|
||||
this.config.logger.info(
|
||||
{ responseLength: assistantMessage.length },
|
||||
'LLM response received'
|
||||
);
|
||||
|
||||
// Save user message and assistant response to conversation store
|
||||
if (this.conversationStore) {
|
||||
await this.conversationStore.saveMessage(
|
||||
this.config.userId, this.config.sessionId, 'user', message.content, undefined, channelKey
|
||||
);
|
||||
await this.conversationStore.saveMessage(
|
||||
this.config.userId, this.config.sessionId, 'assistant', assistantMessage, undefined, channelKey
|
||||
this.config.userId, this.config.sessionId, 'assistant', finalContent,
|
||||
blobRefs.length > 0 ? { blobs: blobRefs } : undefined,
|
||||
channelKey
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: `msg_${Date.now()}`,
|
||||
sessionId: message.sessionId,
|
||||
content: assistantMessage,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
/**
|
||||
* Handle incoming message from user.
|
||||
* Consumes streamMessage and dispatches events to the channel adapter for
|
||||
* backward compatibility with Telegram and other non-streaming callers.
|
||||
*/
|
||||
async handleMessage(message: InboundMessage): Promise<OutboundMessage> {
|
||||
let finalContent = '';
|
||||
try {
|
||||
for await (const event of this.streamMessage(message)) {
|
||||
switch (event.type) {
|
||||
case 'chunk':
|
||||
this.channelAdapter?.sendChunk(event.content);
|
||||
break;
|
||||
case 'tool_call':
|
||||
this.channelAdapter?.sendToolCall?.(event.toolName, event.label);
|
||||
break;
|
||||
case 'image':
|
||||
this.channelAdapter?.sendImage({ data: event.data, mimeType: event.mimeType, caption: event.caption });
|
||||
break;
|
||||
case 'error':
|
||||
this.channelAdapter?.sendText?.({ text: `An unrecoverable error occurred in the ${event.source}.` });
|
||||
break;
|
||||
case 'done':
|
||||
finalContent = event.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.config.logger.error({ error }, 'Error processing message');
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
messageId: `msg_${Date.now()}`,
|
||||
sessionId: message.sessionId,
|
||||
content: finalContent,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -817,21 +992,27 @@ export class AgentHarness {
|
||||
python_write: 'Coding...',
|
||||
python_read: 'Inspecting...',
|
||||
execute_research: 'Running script...',
|
||||
backtest_strategy: 'Running backtest...',
|
||||
backtest_strategy: 'Backtesting...',
|
||||
list_active_strategies: 'Checking active strategies...',
|
||||
web_explore: 'Searching the web...',
|
||||
strategy: 'Coding a strategy...',
|
||||
};
|
||||
return labels[toolName] ?? `Running ${toolName}...`;
|
||||
return labels[toolName] ?? `Running ${toolName} tool...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tool result to extract images and send via channel adapter.
|
||||
* Returns text-only version for LLM context (no base64 image data).
|
||||
*/
|
||||
private processToolResult(result: string, toolName: string): string {
|
||||
private extractImagesFromToolResult(
|
||||
result: string,
|
||||
toolName: string
|
||||
): { cleanedResult: string; images: Array<{ data: string; mimeType: string; caption?: string }> } {
|
||||
const noImages = { cleanedResult: String(result || ''), images: [] };
|
||||
|
||||
// Most tools return plain strings - only process JSON results
|
||||
if (!result || typeof result !== 'string') {
|
||||
return String(result || '');
|
||||
return noImages;
|
||||
}
|
||||
|
||||
// Try to parse as JSON
|
||||
@@ -840,7 +1021,7 @@ export class AgentHarness {
|
||||
parsedResult = JSON.parse(result);
|
||||
} catch {
|
||||
// Not JSON, return as-is
|
||||
return result;
|
||||
return noImages;
|
||||
}
|
||||
|
||||
// Check if result has images array (from ResearchSubagent)
|
||||
@@ -850,19 +1031,11 @@ export class AgentHarness {
|
||||
'Extracting images from tool result'
|
||||
);
|
||||
|
||||
// Send each image via channel adapter
|
||||
const images: Array<{ data: string; mimeType: string; caption?: string }> = [];
|
||||
for (const image of parsedResult.images) {
|
||||
if (image.data && image.mimeType) {
|
||||
if (this.channelAdapter) {
|
||||
this.config.logger.debug({ mimeType: image.mimeType }, 'Sending image to channel');
|
||||
this.channelAdapter.sendImage({
|
||||
data: image.data,
|
||||
mimeType: image.mimeType,
|
||||
caption: undefined,
|
||||
});
|
||||
} else {
|
||||
this.config.logger.warn('No channel adapter set, cannot send image');
|
||||
}
|
||||
this.config.logger.debug({ mimeType: image.mimeType }, 'Extracted image from tool result');
|
||||
images.push({ data: image.data, mimeType: image.mimeType, caption: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,15 +1045,13 @@ export class AgentHarness {
|
||||
images: undefined,
|
||||
imageCount: parsedResult.images.length,
|
||||
};
|
||||
|
||||
// Clean up undefined values
|
||||
Object.keys(textOnlyResult).forEach(key => {
|
||||
if (textOnlyResult[key] === undefined) {
|
||||
delete textOnlyResult[key];
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify(textOnlyResult);
|
||||
return { cleanedResult: JSON.stringify(textOnlyResult), images };
|
||||
}
|
||||
|
||||
// Check for nested chart_images object
|
||||
@@ -890,20 +1061,12 @@ export class AgentHarness {
|
||||
'Extracting chart images from tool result'
|
||||
);
|
||||
|
||||
// Send each chart image via channel adapter
|
||||
const images: Array<{ data: string; mimeType: string; caption?: string }> = [];
|
||||
for (const [chartId, chartData] of Object.entries(parsedResult.chart_images)) {
|
||||
const chart = chartData as any;
|
||||
if (chart.type === 'image' && chart.data) {
|
||||
if (this.channelAdapter) {
|
||||
this.config.logger.debug({ chartId }, 'Sending chart image to channel');
|
||||
this.channelAdapter.sendImage({
|
||||
data: chart.data,
|
||||
mimeType: 'image/png',
|
||||
caption: undefined,
|
||||
});
|
||||
} else {
|
||||
this.config.logger.warn('No channel adapter set, cannot send chart image');
|
||||
}
|
||||
this.config.logger.debug({ chartId }, 'Extracted chart image from tool result');
|
||||
images.push({ data: chart.data, mimeType: 'image/png', caption: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -913,19 +1076,17 @@ export class AgentHarness {
|
||||
chart_images: undefined,
|
||||
chartCount: Object.keys(parsedResult.chart_images).length,
|
||||
};
|
||||
|
||||
// Clean up undefined values
|
||||
Object.keys(textOnlyResult).forEach(key => {
|
||||
if (textOnlyResult[key] === undefined) {
|
||||
delete textOnlyResult[key];
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify(textOnlyResult);
|
||||
return { cleanedResult: JSON.stringify(textOnlyResult), images };
|
||||
}
|
||||
|
||||
// No images found, return stringified result
|
||||
return result;
|
||||
// No images found, return as-is
|
||||
return { cleanedResult: result, images: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user