@hashgraphonline/standards-agent-kit
Version:
A modular SDK for building on-chain autonomous agents using Hashgraph Online Standards, including HCS-10 for agent discovery and communication.
484 lines (448 loc) • 16.2 kB
text/typescript
import { TransactionReceipt, PrivateKey } from '@hashgraph/sdk';
import {
HCS10Client as StandardSDKClient,
AgentBuilder,
InboundTopicType as StandardInboundTopicType,
AIAgentCapability as StandardAIAgentCapability,
AgentRegistrationResult,
WaitForConnectionConfirmationResponse,
ProfileResponse as SDKProfileResponse,
HCSMessage,
LogLevel,
Logger,
FeeConfigBuilderInterface,
SocialPlatform,
} from '@hashgraphonline/standards-sdk';
import { AgentMetadata, AgentChannels } from './types';
import { encryptMessage } from '../utils/Encryption';
import { IStateManager } from '../state/state-types';
// Keep type alias as they were removed accidentally
type StandardHandleConnectionRequest = InstanceType<
typeof StandardSDKClient
>['handleConnectionRequest'];
type HandleConnectionRequestResponse = Awaited<
ReturnType<StandardHandleConnectionRequest>
>;
export type StandardNetworkType = 'mainnet' | 'testnet';
export interface ClientValidationOptions {
accountId: string;
privateKey: string;
network?: StandardNetworkType;
stateManager?: IStateManager;
}
export interface HCSMessageWithTimestamp extends HCSMessage {
timestamp: number;
data?: string;
sequence_number: number;
}
export interface ExtendedAgentMetadata extends AgentMetadata {
pfpBuffer?: Buffer;
pfpFileName?: string;
feeConfig?: FeeConfigBuilderInterface;
}
/**
* HCS10Client wraps the HCS-10 functionalities using the @hashgraphonline/standards-sdk.
* - Creates and registers agents using the standard SDK flow.
* - Manages agent communication channels (handled by standard SDK).
* - Sends messages on Hedera topics (currently manual, potential for standard SDK integration).
*/
export class HCS10Client {
public standardClient: StandardSDKClient;
private useEncryption: boolean;
public agentChannels?: AgentChannels;
public guardedRegistryBaseUrl: string;
public logger: Logger;
constructor(
operatorId: string,
operatorPrivateKey: string,
network: StandardNetworkType,
options?: {
useEncryption?: boolean;
registryUrl?: string;
logLevel?: LogLevel;
}
) {
this.standardClient = new StandardSDKClient({
network: network,
operatorId: operatorId,
operatorPrivateKey: operatorPrivateKey,
guardedRegistryBaseUrl: options?.registryUrl,
logLevel: options?.logLevel,
});
this.guardedRegistryBaseUrl = options?.registryUrl || '';
this.useEncryption = options?.useEncryption || false;
const shouldSilence = process.env.DISABLE_LOGGING === 'true';
this.logger = new Logger({
level: options?.logLevel || 'info',
silent: shouldSilence,
});
}
public getOperatorId(): string {
const operator = this.standardClient.getClient().operatorAccountId;
if (!operator) {
throw new Error('Operator Account ID not configured in standard client.');
}
return operator.toString();
}
public getNetwork(): StandardNetworkType {
return this.standardClient.getNetwork() as StandardNetworkType;
}
public async handleConnectionRequest(
inboundTopicId: string,
requestingAccountId: string,
connectionRequestId: number,
feeConfig?: FeeConfigBuilderInterface
): Promise<HandleConnectionRequestResponse> {
try {
const result = await this.standardClient.handleConnectionRequest(
inboundTopicId,
requestingAccountId,
connectionRequestId,
feeConfig
);
return result;
} catch (error) {
this.logger.error(
`Error handling connection request #${connectionRequestId} for topic ${inboundTopicId}:`,
error
);
throw new Error(
`Failed to handle connection request: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Retrieves the profile for a given account ID using the standard SDK.
*/
public async getAgentProfile(accountId: string): Promise<SDKProfileResponse> {
return this.standardClient.retrieveProfile(accountId);
}
/**
* Exposes the standard SDK's submitConnectionRequest method.
*/
public async submitConnectionRequest(
inboundTopicId: string,
memo: string
): Promise<TransactionReceipt> {
return this.standardClient.submitConnectionRequest(
inboundTopicId,
memo
) as Promise<TransactionReceipt>;
}
/**
* Exposes the standard SDK's waitForConnectionConfirmation method.
*/
public async waitForConnectionConfirmation(
outboundTopicId: string,
connectionRequestId: number,
maxAttempts = 60,
delayMs = 2000
): Promise<WaitForConnectionConfirmationResponse> {
return this.standardClient.waitForConnectionConfirmation(
outboundTopicId,
connectionRequestId,
maxAttempts,
delayMs
);
}
/**
* Creates and registers an agent using the standard SDK's HCS10Client.
* This handles account creation, key generation, topic setup, and registration.
*
* When metadata includes fee configuration:
* 1. The properties.feeConfig will be passed to the AgentBuilder
* 2. The properties.inboundTopicType will be set to FEE_BASED
* 3. The SDK's createAndRegisterAgent will apply the fees to the agent's inbound topic
*
* @param metadata - The agent's metadata, potentially including pfpBuffer, pfpFileName,
* and fee configuration in properties.feeConfig
* @returns The registration result from the standard SDK, containing accountId, keys, topics etc.
*/
public async createAndRegisterAgent(
metadata: ExtendedAgentMetadata
): Promise<AgentRegistrationResult> {
const builder = new AgentBuilder()
.setName(metadata.name)
.setBio(metadata.description || '')
.setCapabilities(
metadata.capabilities
? metadata.capabilities
: [StandardAIAgentCapability.TEXT_GENERATION]
)
.setType((metadata.type || 'autonomous') as 'autonomous' | 'manual')
.setModel(metadata.model || 'agent-model-2024')
.setNetwork(this.getNetwork())
.setInboundTopicType(StandardInboundTopicType.PUBLIC);
if (metadata?.feeConfig) {
builder.setInboundTopicType(StandardInboundTopicType.FEE_BASED);
builder.setFeeConfig(metadata.feeConfig);
}
if (metadata.pfpBuffer && metadata.pfpFileName) {
if (metadata.pfpBuffer.byteLength === 0) {
this.logger.warn(
'Provided PFP buffer is empty. Skipping profile picture.'
);
} else {
this.logger.info(
`Setting profile picture: ${metadata.pfpFileName} (${metadata.pfpBuffer.byteLength} bytes)`
);
builder.setProfilePicture(metadata.pfpBuffer, metadata.pfpFileName);
}
} else {
this.logger.warn(
'Profile picture not provided in metadata. Agent creation might fail if required by the underlying SDK builder.'
);
}
if (metadata.social) {
Object.entries(metadata.social).forEach(([platform, handle]) => {
builder.addSocial(platform as SocialPlatform, handle);
});
}
if (metadata.properties) {
Object.entries(metadata.properties).forEach(([key, value]) => {
builder.addProperty(key, value);
});
}
try {
const hasFees = Boolean(metadata?.feeConfig);
const result = await this.standardClient.createAndRegisterAgent(builder, {
initialBalance: hasFees ? 50 : undefined,
});
if (
result?.metadata?.inboundTopicId &&
result?.metadata?.outboundTopicId
) {
this.agentChannels = {
inboundTopicId: result.metadata.inboundTopicId,
outboundTopicId: result.metadata.outboundTopicId,
};
}
return result;
} catch (error) {
this.logger.error('Error during agent creation/registration:', error);
throw new Error(
`Failed to create/register agent: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Sends a structured HCS-10 message to the specified topic using the standard SDK client.
* Handles potential inscription for large messages.
*
* @param topicId - The target topic ID (likely a connection topic).
* @param operatorId - The operator ID string (e.g., "inboundTopic@accountId").
* @param data - The actual message content/data.
* @param memo - Optional memo for the message.
* @param submitKey - Optional private key for topics requiring specific submit keys.
* @returns A confirmation status string from the transaction receipt.
*/
public async sendMessage(
topicId: string,
data: string,
memo?: string,
submitKey?: PrivateKey
): Promise<number | undefined> {
if (this.useEncryption) {
data = encryptMessage(data);
}
try {
const messageResponse = await this.standardClient.sendMessage(
topicId,
data,
memo,
submitKey
);
return messageResponse.topicSequenceNumber?.toNumber();
} catch (error) {
this.logger.error(`Error sending message to topic ${topicId}:`, error);
throw new Error(
`Failed to send message: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Retrieves messages from a topic using the standard SDK client.
*
* @param topicId - The topic ID to get messages from.
* @returns Messages from the topic, mapped to the expected format.
*/
public async getMessages(topicId: string): Promise<{
messages: HCSMessageWithTimestamp[];
}> {
try {
const result = await this.standardClient.getMessages(topicId);
const mappedMessages = result.messages.map((sdkMessage) => {
const timestamp = sdkMessage?.created?.getTime() || 0;
return {
...sdkMessage,
timestamp: timestamp,
data: sdkMessage.data,
sequence_number: sdkMessage.sequence_number,
};
});
mappedMessages.sort(
(a: { timestamp: number }, b: { timestamp: number }) =>
a.timestamp - b.timestamp
);
return { messages: mappedMessages as HCSMessageWithTimestamp[] };
} catch (error) {
this.logger.error(`Error getting messages from topic ${topicId}:`, error);
return { messages: [] };
}
}
public async getMessageStream(topicId: string): Promise<{
messages: HCSMessage[];
}> {
const result = this.standardClient.getMessageStream(topicId);
return result as Promise<{ messages: HCSMessage[] }>;
}
/**
* Retrieves content from an inscribed message using the standard SDK client.
* @param inscriptionIdOrData - The inscription ID (hcs://...) or potentially raw data string.
* @returns The resolved message content.
*/
public async getMessageContent(inscriptionIdOrData: string): Promise<string> {
try {
const content = await this.standardClient.getMessageContent(
inscriptionIdOrData
);
return content as string;
} catch (error) {
this.logger.error(
`Error retrieving message content for: ${inscriptionIdOrData}`,
error
);
throw new Error(
`Failed to retrieve message content: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Retrieves the inbound topic ID associated with the current operator.
* This typically involves fetching the operator's own HCS-10 profile.
* @returns A promise that resolves to the operator's inbound topic ID.
* @throws {Error} If the operator ID cannot be determined or the profile/topic cannot be retrieved.
*/
public async getInboundTopicId(): Promise<string> {
try {
const operatorId = this.getOperatorId();
this.logger.info(
`[HCS10Client] Retrieving profile for operator ${operatorId} to find inbound topic...`
);
const profileResponse = await this.getAgentProfile(operatorId);
if (profileResponse.success && profileResponse.topicInfo?.inboundTopic) {
this.logger.info(
`[HCS10Client] Found inbound topic for operator ${operatorId}: ${profileResponse.topicInfo.inboundTopic}`
);
return profileResponse.topicInfo.inboundTopic;
} else {
throw new Error(
`Could not retrieve inbound topic from profile for ${operatorId}. Profile success: ${profileResponse.success}, Error: ${profileResponse.error}`
);
}
} catch (error) {
this.logger.error(
`[HCS10Client] Error fetching operator's inbound topic ID (${this.getOperatorId()}):`,
error
);
const operatorId = this.getOperatorId();
let detailedMessage = `Failed to get inbound topic ID for operator ${operatorId}.`;
if (
error instanceof Error &&
error.message.includes('does not have a valid HCS-11 memo')
) {
detailedMessage += ` The account profile may not exist or is invalid. Please ensure this operator account (${operatorId}) is registered as an HCS-10 agent. You might need to register it first (e.g., using the 'register_agent' tool or SDK function).`;
} else if (error instanceof Error) {
detailedMessage += ` Reason: ${error.message}`;
} else {
detailedMessage += ` Unexpected error: ${String(error)}`;
}
throw new Error(detailedMessage);
}
}
/**
* Retrieves the configured operator account ID and private key.
* Required by tools needing to identify the current agent instance.
*/
public getAccountAndSigner(): { accountId: string; signer: PrivateKey } {
const result = this.standardClient.getAccountAndSigner();
return {
accountId: result.accountId,
signer: result.signer as unknown as PrivateKey,
};
}
/**
* Retrieves the outbound topic ID for the current operator.
* Fetches the operator's profile if necessary.
* @returns The outbound topic ID string.
* @throws If the outbound topic cannot be determined.
*/
public async getOutboundTopicId(): Promise<string> {
const operatorId = this.getOperatorId();
const profile = await this.getAgentProfile(operatorId);
if (profile.success && profile.topicInfo?.outboundTopic) {
return profile.topicInfo.outboundTopic;
} else {
throw new Error(
`Could not retrieve outbound topic from profile for ${operatorId}. Profile success: ${profile.success}, Error: ${profile.error}`
);
}
}
public setClient(accountId: string, privateKey: string): StandardSDKClient {
this.standardClient = new StandardSDKClient({
network: this.getNetwork(),
operatorId: accountId,
operatorPrivateKey: privateKey,
guardedRegistryBaseUrl: this.guardedRegistryBaseUrl,
});
return this.standardClient;
}
/**
* Validates that the operator account exists and has proper access for agent operations
*/
private async validateOperator(options: ClientValidationOptions): Promise<{
isValid: boolean;
operator?: { accountId: string };
error?: string;
}> {
try {
// Set up client with provided operator details
this.setClient(options.accountId, options.privateKey);
// Check if we can retrieve the operator ID
const operatorId = this.getOperatorId();
// If we got this far, basic validation passed
return {
isValid: true,
operator: { accountId: operatorId },
};
} catch (error) {
this.logger.error(`Validation error: ${error}`);
return {
isValid: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
async initializeWithValidation(options: ClientValidationOptions): Promise<{
isValid: boolean;
operator?: { accountId: string };
error?: string;
}> {
const validationResult = await this.validateOperator(options);
if (validationResult.isValid) {
// If we have access to the state manager, initialize its connections manager
if (options.stateManager) {
options.stateManager.initializeConnectionsManager(this.standardClient);
}
}
return validationResult;
}
}