@hashgraphonline/conversational-agent
Version:
Hashgraph Online conversational AI agent implementing HCS-10 communication, HCS-2 registries, and content inscription on Hedera
801 lines (712 loc) • 23.8 kB
text/typescript
import {
ServerSigner,
getAllHederaCorePlugins,
BasePlugin,
} from 'hedera-agent-kit';
import {
HederaMirrorNode,
Logger,
type NetworkType,
} from '@hashgraphonline/standards-sdk';
import { createAgent } from './agent-factory';
import { LangChainProvider } from './providers';
import type { ChatResponse, ConversationContext } from './base-agent';
import { ChatOpenAI } from '@langchain/openai';
import { ChatAnthropic } from '@langchain/anthropic';
import { HumanMessage, AIMessage } from '@langchain/core/messages';
import type { AgentOperationalMode, MirrorNodeConfig } from 'hedera-agent-kit';
import { HCS10Plugin } from './plugins/hcs-10/HCS10Plugin';
import { HCS2Plugin } from './plugins/hcs-2/HCS2Plugin';
import { InscribePlugin } from './plugins/inscribe/InscribePlugin';
import { HbarPlugin } from './plugins/hbar/HbarPlugin';
import { OpenConvaiState } from '@hashgraphonline/standards-agent-kit';
import type { IStateManager } from '@hashgraphonline/standards-agent-kit';
import { PrivateKey } from '@hashgraph/sdk';
import { getSystemMessage } from './config/system-message';
import type { MCPServerConfig, MCPConnectionStatus } from './mcp/types';
import { ContentStoreManager } from './services/ContentStoreManager';
import { SmartMemoryManager, type SmartMemoryConfig } from './memory';
import {
createEntityTools,
ResolveEntitiesTool,
ExtractEntitiesTool,
} from './tools/EntityResolverTool';
export type ToolDescriptor = {
name: string;
namespace?: string;
};
export type ChatHistoryItem = {
type: 'human' | 'ai';
content: string;
};
export type AgentInstance = ReturnType<typeof createAgent>;
export type MirrorNetwork = 'testnet' | 'mainnet' | 'previewnet';
const DEFAULT_MODEL_NAME = 'gpt-4o';
const DEFAULT_TEMPERATURE = 0.1;
const DEFAULT_NETWORK = 'testnet';
const DEFAULT_OPERATIONAL_MODE: AgentOperationalMode = 'autonomous';
export interface ConversationalAgentOptions {
accountId: string;
privateKey: string;
network?: NetworkType;
openAIApiKey: string;
openAIModelName?: string;
llmProvider?: 'openai' | 'anthropic';
verbose?: boolean;
operationalMode?: AgentOperationalMode;
userAccountId?: string;
customSystemMessagePreamble?: string;
customSystemMessagePostamble?: string;
additionalPlugins?: BasePlugin[];
stateManager?: IStateManager;
scheduleUserTransactionsInBytesMode?: boolean;
mirrorNodeConfig?: MirrorNodeConfig;
disableLogging?: boolean;
enabledPlugins?: string[];
toolFilter?: (tool: { name: string; namespace?: string }) => boolean;
mcpServers?: MCPServerConfig[];
/** Enable automatic entity memory functionality (default: true) */
entityMemoryEnabled?: boolean;
/** Configuration for entity memory system */
entityMemoryConfig?: SmartMemoryConfig;
}
/**
* The ConversationalAgent class is an optional wrapper around the HederaConversationalAgent class,
* which includes the OpenConvAIPlugin and the OpenConvaiState by default.
* If you want to use a different plugin or state manager, you can pass them in the options.
* This class is not required and the plugin can be used directly with the HederaConversationalAgent class.
*
* @param options - The options for the ConversationalAgent.
* @returns A new instance of the ConversationalAgent class.
*/
export class ConversationalAgent {
protected agent?: AgentInstance;
public hcs10Plugin: HCS10Plugin;
public hcs2Plugin: HCS2Plugin;
public inscribePlugin: InscribePlugin;
public hbarPlugin: HbarPlugin;
public stateManager: IStateManager;
private options: ConversationalAgentOptions;
public logger: Logger;
public contentStoreManager?: ContentStoreManager;
public memoryManager?: SmartMemoryManager | undefined;
private entityTools?: {
resolveEntities: ResolveEntitiesTool;
extractEntities: ExtractEntitiesTool;
};
constructor(options: ConversationalAgentOptions) {
this.options = options;
this.stateManager = options.stateManager || new OpenConvaiState();
this.hcs10Plugin = new HCS10Plugin();
this.hcs2Plugin = new HCS2Plugin();
this.inscribePlugin = new InscribePlugin();
this.hbarPlugin = new HbarPlugin();
this.logger = new Logger({
module: 'ConversationalAgent',
silent: options.disableLogging || false,
});
if (this.options.entityMemoryEnabled !== false) {
if (!options.openAIApiKey) {
throw new Error(
'OpenAI API key is required when entity memory is enabled'
);
}
this.memoryManager = new SmartMemoryManager(
this.options.entityMemoryConfig
);
this.logger.info('Entity memory initialized');
this.entityTools = createEntityTools(options.openAIApiKey, 'gpt-4o-mini');
this.logger.info('LLM-based entity resolver tools initialized');
}
}
/**
* Initialize the conversational agent with Hedera Hashgraph connection and AI configuration
* @throws {Error} If account ID or private key is missing
* @throws {Error} If initialization fails
*/
async initialize(): Promise<void> {
const {
accountId,
privateKey,
network = DEFAULT_NETWORK,
openAIApiKey,
openAIModelName = DEFAULT_MODEL_NAME,
llmProvider = 'openai',
} = this.options;
this.validateOptions(accountId, privateKey);
try {
const privateKeyInstance = await this.detectPrivateKeyType(
accountId!,
privateKey!,
network
);
const serverSigner = new ServerSigner(
accountId!,
privateKeyInstance,
network as MirrorNetwork
);
let llm: ChatOpenAI | ChatAnthropic;
if (llmProvider === 'anthropic') {
llm = new ChatAnthropic({
apiKey: openAIApiKey,
modelName: openAIModelName || 'claude-3-5-sonnet-20241022',
temperature: DEFAULT_TEMPERATURE,
});
} else {
const modelName = openAIModelName || 'gpt-4o-mini';
const isGPT5Model =
modelName.toLowerCase().includes('gpt-5') ||
modelName.toLowerCase().includes('gpt5');
llm = new ChatOpenAI({
apiKey: openAIApiKey,
modelName: openAIModelName,
...(isGPT5Model
? { temperature: 1 }
: { temperature: DEFAULT_TEMPERATURE }),
});
}
const allPlugins = this.preparePlugins();
const agentConfig = this.createAgentConfig(serverSigner, llm, allPlugins);
this.agent = createAgent(agentConfig);
this.configureHCS10Plugin(allPlugins);
this.contentStoreManager = new ContentStoreManager();
await this.contentStoreManager.initialize();
this.logger.info(
'ContentStoreManager initialized for content reference support'
);
await this.agent.boot();
if (this.agent) {
const cfg = agentConfig;
cfg.filtering = cfg.filtering || {};
const originalPredicate = cfg.filtering.toolPredicate as
| ((t: ToolDescriptor) => boolean)
| undefined;
const userPredicate = this.options.toolFilter;
cfg.filtering.toolPredicate = (tool: ToolDescriptor): boolean => {
if (tool && tool.name === 'hedera-account-transfer-hbar') {
return false;
}
if (tool && tool.name === 'hedera-hts-airdrop-token') {
return false;
}
if (originalPredicate && !originalPredicate(tool)) {
return false;
}
if (userPredicate && !userPredicate(tool)) {
return false;
}
return true;
};
}
if (this.options.mcpServers && this.options.mcpServers.length > 0) {
this.connectMCP();
}
} catch (error) {
this.logger.error('Failed to initialize ConversationalAgent:', error);
throw error;
}
}
/**
* Get the HCS-10 plugin instance
* @returns {HCS10Plugin} The HCS-10 plugin instance
*/
getPlugin(): HCS10Plugin {
return this.hcs10Plugin;
}
/**
* Get the state manager instance
* @returns {IStateManager} The state manager instance
*/
getStateManager(): IStateManager {
return this.stateManager;
}
/**
* Get the underlying agent instance
* @returns {ReturnType<typeof createAgent>} The agent instance
* @throws {Error} If agent is not initialized
*/
getAgent(): ReturnType<typeof createAgent> {
if (!this.agent) {
throw new Error('Agent not initialized. Call initialize() first.');
}
return this.agent;
}
/**
* Get the conversational agent instance (alias for getAgent)
* @returns {ReturnType<typeof createAgent>} The agent instance
* @throws {Error} If agent is not initialized
*/
getConversationalAgent(): ReturnType<typeof createAgent> {
return this.getAgent();
}
/**
* Process a message through the conversational agent
* @param {string} message - The message to process
* @param {Array<{type: 'human' | 'ai'; content: string}>} chatHistory - Previous chat history
* @returns {Promise<ChatResponse>} The agent's response
* @throws {Error} If agent is not initialized
*/
async processMessage(
message: string,
chatHistory: ChatHistoryItem[] = []
): Promise<ChatResponse> {
if (!this.agent) {
throw new Error('Agent not initialized. Call initialize() first.');
}
try {
const resolvedMessage = this.memoryManager
? await this.resolveEntitiesInMessage(message)
: message;
const messages = chatHistory.map((msg) => {
if (msg.type === 'human') {
return new HumanMessage(msg.content);
} else {
return new AIMessage(msg.content);
}
});
const context: ConversationContext = {
messages,
};
const response = await this.agent.chat(resolvedMessage, context);
if (this.memoryManager) {
await this.extractAndStoreEntities(response, message);
}
this.logger.info('Message processed successfully');
return response;
} catch (error) {
this.logger.error('Error processing message:', error);
throw error;
}
}
/**
* Validates initialization options and throws if required fields are missing.
*
* @param accountId - The Hedera account ID
* @param privateKey - The private key for the account
* @throws {Error} If required fields are missing
*/
private validateOptions(accountId?: string, privateKey?: string): void {
if (!accountId || !privateKey) {
throw new Error('Account ID and private key are required');
}
}
/**
* Prepares the list of plugins to use based on configuration.
*
* @returns Array of plugins to initialize with the agent
*/
private preparePlugins(): BasePlugin[] {
const { additionalPlugins = [], enabledPlugins } = this.options;
const standardPlugins = [
this.hcs10Plugin,
this.hcs2Plugin,
this.inscribePlugin,
this.hbarPlugin,
];
const corePlugins = getAllHederaCorePlugins();
if (enabledPlugins) {
const enabledSet = new Set(enabledPlugins);
const filteredPlugins = [...standardPlugins, ...corePlugins].filter(
(plugin) => enabledSet.has(plugin.id)
);
return [...filteredPlugins, ...additionalPlugins];
}
return [...standardPlugins, ...corePlugins, ...additionalPlugins];
}
/**
* Creates the agent configuration object.
*
* @param serverSigner - The server signer instance
* @param llm - The language model instance
* @param allPlugins - Array of plugins to use
* @returns Configuration object for creating the agent
*/
private createAgentConfig(
serverSigner: ServerSigner,
llm: ChatOpenAI | ChatAnthropic,
allPlugins: BasePlugin[]
): Parameters<typeof createAgent>[0] {
const {
operationalMode = DEFAULT_OPERATIONAL_MODE,
userAccountId,
scheduleUserTransactionsInBytesMode,
customSystemMessagePreamble,
customSystemMessagePostamble,
verbose = false,
mirrorNodeConfig,
disableLogging,
accountId = '',
} = this.options;
return {
framework: 'langchain',
signer: serverSigner,
execution: {
mode: operationalMode === 'autonomous' ? 'direct' : 'bytes',
operationalMode: operationalMode,
...(userAccountId && { userAccountId }),
...(scheduleUserTransactionsInBytesMode !== undefined && {
scheduleUserTransactionsInBytesMode:
scheduleUserTransactionsInBytesMode,
scheduleUserTransactions: scheduleUserTransactionsInBytesMode,
}),
},
ai: {
provider: new LangChainProvider(llm),
temperature: DEFAULT_TEMPERATURE,
},
filtering: {
toolPredicate: (tool: ToolDescriptor): boolean => {
if (tool.name === 'hedera-account-transfer-hbar') return false;
if (this.options.toolFilter && !this.options.toolFilter(tool)) {
return false;
}
return true;
},
},
messaging: {
systemPreamble:
customSystemMessagePreamble || getSystemMessage(accountId),
...(customSystemMessagePostamble && {
systemPostamble: customSystemMessagePostamble,
}),
conciseMode: true,
},
extensions: {
plugins: allPlugins,
...(mirrorNodeConfig && {
mirrorConfig: mirrorNodeConfig as Record<string, unknown>,
}),
},
...(this.options.mcpServers && {
mcp: {
servers: this.options.mcpServers,
autoConnect: false,
},
}),
debug: {
verbose,
silent: disableLogging ?? false,
},
};
}
/**
* Configures the HCS-10 plugin with the state manager.
*
* @param allPlugins - Array of all plugins
*/
private configureHCS10Plugin(allPlugins: BasePlugin[]): void {
const hcs10 = allPlugins.find((p) => p.id === 'hcs-10');
if (hcs10) {
(
hcs10 as BasePlugin & { appConfig?: Record<string, unknown> }
).appConfig = {
stateManager: this.stateManager,
};
}
}
/**
* Create a ConversationalAgent with specific plugins enabled
*/
private static withPlugins(
options: ConversationalAgentOptions,
plugins: string[]
): ConversationalAgent {
return new ConversationalAgent({
...options,
enabledPlugins: plugins,
});
}
/**
* Create a ConversationalAgent with only HTS (Hedera Token Service) tools enabled
*/
static withHTS(options: ConversationalAgentOptions): ConversationalAgent {
return this.withPlugins(options, ['hts-token']);
}
/**
* Create a ConversationalAgent with only HCS-2 tools enabled
*/
static withHCS2(options: ConversationalAgentOptions): ConversationalAgent {
return this.withPlugins(options, ['hcs-2']);
}
/**
* Create a ConversationalAgent with only HCS-10 tools enabled
*/
static withHCS10(options: ConversationalAgentOptions): ConversationalAgent {
return this.withPlugins(options, ['hcs-10']);
}
/**
* Create a ConversationalAgent with only inscription tools enabled
*/
static withInscribe(
options: ConversationalAgentOptions
): ConversationalAgent {
return this.withPlugins(options, ['inscribe']);
}
/**
* Create a ConversationalAgent with only account management tools enabled
*/
static withAccount(options: ConversationalAgentOptions): ConversationalAgent {
return this.withPlugins(options, ['account']);
}
/**
* Create a ConversationalAgent with only file service tools enabled
*/
static withFileService(
options: ConversationalAgentOptions
): ConversationalAgent {
return this.withPlugins(options, ['file-service']);
}
/**
* Create a ConversationalAgent with only consensus service tools enabled
*/
static withConsensusService(
options: ConversationalAgentOptions
): ConversationalAgent {
return this.withPlugins(options, ['consensus-service']);
}
/**
* Create a ConversationalAgent with only smart contract tools enabled
*/
static withSmartContract(
options: ConversationalAgentOptions
): ConversationalAgent {
return this.withPlugins(options, ['smart-contract']);
}
/**
* Create a ConversationalAgent with all HCS standards plugins
*/
static withAllStandards(
options: ConversationalAgentOptions
): ConversationalAgent {
return this.withPlugins(options, ['hcs-10', 'hcs-2', 'inscribe']);
}
/**
* Create a ConversationalAgent with minimal Hedera tools (no HCS standards)
*/
static minimal(options: ConversationalAgentOptions): ConversationalAgent {
return this.withPlugins(options, []);
}
/**
* Create a ConversationalAgent with MCP servers configured
*/
static withMCP(
options: ConversationalAgentOptions,
mcpServers: MCPServerConfig[]
): ConversationalAgent {
return new ConversationalAgent({
...options,
mcpServers,
});
}
/**
* Detect the private key type by querying the account info from mirror node
* @param {string} accountId - The Hedera account ID
* @param {string} privateKey - The private key string
* @param {NetworkType} network - The Hedera Hashgraph
* @returns {Promise<PrivateKey>} The appropriate PrivateKey instance
*/
private async detectPrivateKeyType(
accountId: string,
privateKey: string,
network: NetworkType
): Promise<PrivateKey> {
const mirrorNode = new HederaMirrorNode(network as 'testnet' | 'mainnet');
const accountInfo = await mirrorNode.requestAccount(accountId);
const keyType = accountInfo?.key?._type || '';
if (keyType?.toLowerCase()?.includes('ecdsa')) {
return PrivateKey.fromStringECDSA(privateKey);
} else {
return PrivateKey.fromStringED25519(privateKey);
}
}
/**
* Resolve entity references using LLM-based resolver
* @param content - Message content to resolve
* @returns Resolved message content with entity IDs replaced
*/
private async resolveEntitiesInMessage(content: string): Promise<string> {
if (!this.memoryManager || !this.entityTools) {
return content;
}
try {
const entities = this.memoryManager.getEntityAssociations();
if (entities.length === 0) {
this.logger.info('No entities in memory, skipping resolution');
return content;
}
this.logger.info(
`Starting LLM-based entity resolution for: "${content.substring(
0,
100
)}..."`
);
const resolvedContent = await this.entityTools.resolveEntities.call({
message: content,
entities: entities.map((e) => ({
entityId: e.entityId,
entityName: e.entityName,
entityType: e.entityType,
})),
});
if (resolvedContent !== content) {
this.logger.info(
`Entity resolution completed. Original: "${content}" -> Resolved: "${resolvedContent}"`
);
}
return resolvedContent;
} catch (error) {
this.logger.error('Entity resolution failed:', error);
throw error;
}
}
/**
* Extract and store entities from agent responses
* @param response - Agent response containing potential entity information
* @param originalMessage - Original user message for context
*/
private async extractAndStoreEntities(
response: unknown,
originalMessage: string
): Promise<void> {
if (!this.memoryManager || !this.entityTools) {
return;
}
try {
this.logger.info('Starting LLM-based entity extraction');
const responseText = this.extractResponseText(response);
const entitiesJson = await this.entityTools.extractEntities.call({
response: responseText,
userMessage: originalMessage,
});
try {
const entities = JSON.parse(entitiesJson);
for (const entity of entities) {
this.logger.info(
`Storing entity: ${entity.name} (${entity.type}) -> ${entity.id}`
);
const transactionId = this.extractTransactionId(response);
this.memoryManager.storeEntityAssociation(
entity.id,
entity.name,
entity.type,
transactionId
);
}
if (entities.length > 0) {
this.logger.info(
`Stored ${entities.length} entities via LLM extraction`
);
} else {
this.logger.info('No entities found in response via LLM extraction');
}
} catch (parseError) {
this.logger.error(
'Failed to parse extracted entities JSON:',
parseError
);
throw parseError;
}
} catch (error) {
this.logger.error('Entity extraction failed:', error);
throw error;
}
}
/**
* Extract transaction ID from response if available
* @param response - Transaction response
* @returns Transaction ID or undefined
*/
private extractTransactionId(response: unknown): string | undefined {
try {
if (
typeof response === 'object' &&
response &&
'transactionId' in response
) {
return (response as { transactionId?: string }).transactionId;
}
if (typeof response === 'string') {
const match = response.match(
/transaction[\s\w]*ID[\s:"]*([0-9a-fA-F@\.\-]+)/i
);
return match ? match[1] : undefined;
}
return undefined;
} catch {
return undefined;
}
}
/**
* Connect to MCP servers asynchronously
* @private
*/
private connectMCP(): void {
if (!this.agent || !this.options.mcpServers) {
return;
}
this.agent
.connectMCPServers()
.catch((e) => {
this.logger.error('Failed to connect MCP servers:', e);
})
.then(() => {
this.logger.info('MCP servers connected successfully');
});
}
/**
* Get MCP connection status for all servers
* @returns {Map<string, MCPConnectionStatus>} Connection status map
*/
getMCPConnectionStatus(): Map<string, MCPConnectionStatus> {
if (this.agent) {
return this.agent.getMCPConnectionStatus();
}
return new Map();
}
/**
* Check if a specific MCP server is connected
* @param {string} serverName - Name of the server to check
* @returns {boolean} True if connected, false otherwise
*/
isMCPServerConnected(serverName: string): boolean {
if (this.agent) {
const statusMap = this.agent.getMCPConnectionStatus();
const status = statusMap.get(serverName);
return status?.connected ?? false;
}
return false;
}
/**
* Clean up resources
*/
async cleanup(): Promise<void> {
try {
this.logger.info('Cleaning up ConversationalAgent...');
if (this.memoryManager) {
try {
this.memoryManager.dispose();
this.logger.info('Memory manager cleaned up successfully');
} catch (error) {
this.logger.warn('Error cleaning up memory manager:', error);
}
this.memoryManager = undefined;
}
if (this.contentStoreManager) {
await this.contentStoreManager.dispose();
this.logger.info('ContentStoreManager cleaned up');
}
this.logger.info('ConversationalAgent cleanup completed');
} catch (error) {
this.logger.error('Error during cleanup:', error);
}
}
private extractResponseText(response: unknown): string {
if (typeof response === 'string') {
return response;
}
if (response && typeof response === 'object' && 'output' in response) {
return String(response.output);
}
return JSON.stringify(response);
}
}