UNPKG

@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. https://hol.org

814 lines (752 loc) 24.9 kB
import { z } from 'zod'; import { BaseInscriberQueryTool } from './base-inscriber-tools'; import { InscriptionOptions, InscriptionInput, ContentResolverRegistry, Logger, InscriptionResult, } from '@hashgraphonline/standards-sdk'; import { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager'; import { validateHIP412Metadata } from '../../validation/hip412-schemas'; import { contentRefSchema } from '../../validation/content-ref-schemas'; import { generateDefaultMetadata } from '../../utils/metadata-defaults'; import { extendZodSchema, renderConfigs, } from '../../lib/zod-render/schema-extension'; import { createInscriptionSuccess, createInscriptionQuote, createInscriptionError, createInscriptionPending, InscriptionResponse, } from '../../types/inscription-response'; import { FormValidatable } from '../../interfaces/FormValidatable'; import { extractTopicIds, buildInscriptionLinks, } from '../../utils/inscription-utils'; /** * Network-specific Hashinal block configuration for HashLink blocks */ const HASHLINK_BLOCK_CONFIG = { testnet: { blockId: '0.0.6617393', hashLink: 'hcs://12/0.0.6617393', template: '0.0.6617393', }, mainnet: { blockId: '0.0.TBD', hashLink: 'hcs://12/0.0.TBD', template: '0.0.TBD', }, }; /** * Gets the appropriate HashLink block configuration for the specified network. * Provides graceful fallback to testnet for unknown networks or undeployed mainnet blocks. * * @param network The network type to get configuration for * @returns Network-specific block configuration with blockId, hashLink, and template */ function getHashLinkBlockId(network: 'mainnet' | 'testnet'): { blockId: string; hashLink: string; template: string; } { const config = network === 'mainnet' ? HASHLINK_BLOCK_CONFIG.mainnet : HASHLINK_BLOCK_CONFIG.testnet; if (!config || config.blockId === '0.0.TBD') { return HASHLINK_BLOCK_CONFIG.testnet; } return config; } /** * Schema for inscribing Hashinal NFT */ const inscribeHashinalSchema = extendZodSchema( z.object({ url: z .string() .optional() .describe( 'The URL of the content to inscribe as Hashinal NFT (use this OR contentRef)' ), contentRef: contentRefSchema .optional() .describe( 'Content reference ID in format "content-ref:[id]" for already stored content (use this OR url)' ), base64Data: z .string() .optional() .describe( 'Base64 encoded content data (use this if neither url nor contentRef provided)' ), fileName: z .string() .optional() .describe( 'File name for the content (required when using base64Data or contentRef)' ), mimeType: z .string() .optional() .describe('MIME type of the content (e.g., "image/png", "image/jpeg")'), name: z .string() .optional() .describe( 'Display name for the NFT (e.g., "Sunset Landscape #42", "Digital Abstract Art")' ), creator: z .string() .optional() .describe( 'Creator account ID, artist name, or brand (e.g., "0.0.123456", "ArtistName", "StudioBrand")' ), description: z .string() .optional() .describe( 'Meaningful description of the artwork, story, or concept behind this NFT' ), type: z .string() .optional() .describe( 'Category or genre of the NFT (e.g., "Digital Art", "Photography", "Collectible Card")' ), attributes: extendZodSchema( z.array( z.object({ trait_type: z.string(), value: z.union([z.string(), z.number()]), }) ) ) .withRender(renderConfigs.array('NFT Attributes', 'Attribute')) .optional() .describe( 'Collectible traits and characteristics (e.g., "Rarity": "Epic", "Color": "Blue", "Style": "Abstract")' ), properties: z .record(z.unknown()) .optional() .describe('Additional properties'), jsonFileURL: z .string() .url() .optional() .describe('URL to JSON metadata file'), fileStandard: z .enum(['1', '6']) .optional() .default('1') .describe( 'HCS file standard: 1 for static Hashinals (HCS-5), 6 for dynamic Hashinals (HCS-6)' ), tags: z.array(z.string()).optional().describe('Tags to categorize the NFT'), chunkSize: z .number() .int() .positive() .optional() .describe('Chunk size for large files'), waitForConfirmation: z .boolean() .optional() .describe('Whether to wait for inscription confirmation') .default(true), timeoutMs: z .number() .int() .positive() .optional() .describe( 'Timeout in milliseconds for inscription (default: no timeout - waits until completion)' ), quoteOnly: z .boolean() .optional() .default(false) .describe( 'If true, returns a cost quote instead of executing the inscription' ), withHashLinkBlocks: z .boolean() .optional() .default(true) .describe( 'If true, creates interactive HashLink blocks for the inscribed content and returns block data alongside the inscription response' ), renderForm: z .boolean() .optional() .default(true) .describe( 'Whether to show a form to collect metadata. Set to false only if user provided complete metadata including name, description, creator, and attributes.' ), }) ).withRender({ fieldType: 'object', ui: { label: 'Inscribe Hashinal NFT', description: 'Create a Hashinal inscription for NFT minting', }, }); /** * Tool for inscribing Hashinal NFTs */ export class InscribeHashinalTool extends BaseInscriberQueryTool implements FormValidatable { name = 'inscribeHashinal'; description = 'Tool for inscribing Hashinal NFTs. CRITICAL: When user provides content (url/contentRef/base64Data), call with ONLY the content parameters - DO NOT auto-generate name, description, creator, or attributes. A form will be automatically shown to collect metadata from the user. Only include metadata parameters if the user explicitly provided them in their message.'; getEntityResolutionPreferences(): Record<string, string> { return { name: 'literal', description: 'literal', creator: 'literal', attributes: 'literal', properties: 'literal', }; } get specificInputSchema(): z.ZodObject<z.ZodRawShape> { const baseSchema = (inscribeHashinalSchema as z.ZodType & { _def?: { schema?: z.ZodType } }) ._def?.schema || inscribeHashinalSchema; return baseSchema as z.ZodObject<z.ZodRawShape>; } private _schemaWithRenderConfig?: z.ZodObject<z.ZodRawShape>; override get schema(): z.ZodObject<z.ZodRawShape> { if (!this._schemaWithRenderConfig) { const baseSchema = this.specificInputSchema; const schemaWithRender = baseSchema as z.ZodObject<z.ZodRawShape> & { _renderConfig?: { fieldType: string; ui: { label: string; description: string }; }; }; if (!schemaWithRender._renderConfig) { schemaWithRender._renderConfig = { fieldType: 'object', ui: { label: 'Inscribe Hashinal NFT', description: 'Create a Hashinal inscription for NFT minting', }, }; } this._schemaWithRenderConfig = baseSchema; } return this._schemaWithRenderConfig; } /** * Implementation of FormValidatable interface * Determines if a form should be generated for the given input */ shouldGenerateForm(input: unknown): boolean { const logger = new Logger({ module: 'InscribeHashinalTool' }); const inputObj = input as Record<string, unknown>; logger.info('InscribeHashinalTool: Checking if form should be generated', { inputKeys: Object.keys(inputObj || {}), hasContent: !!( inputObj.url || inputObj.contentRef || inputObj.base64Data ), renderFormProvided: 'renderForm' in inputObj, renderFormValue: inputObj.renderForm, }); const hasContentSource = !!( inputObj.url || inputObj.contentRef || inputObj.base64Data ); if (!hasContentSource) { logger.info('InscribeHashinalTool: No content source provided'); return false; } if ('renderForm' in inputObj && inputObj.renderForm === false) { logger.info( 'InscribeHashinalTool: renderForm=false, skipping form generation' ); return false; } const isNonEmptyString = (v: unknown): v is string => { if (typeof v !== 'string') { return false; } if (v.trim().length === 0) { return false; } return true; }; const hasRequiredMetadata = isNonEmptyString(inputObj.name) && isNonEmptyString(inputObj.description) && isNonEmptyString(inputObj.creator); if (hasRequiredMetadata) { logger.info( 'InscribeHashinalTool: Required metadata present, skipping form generation' ); return false; } logger.info( 'InscribeHashinalTool: Content provided, showing form for metadata collection' ); return true; } /** * Implementation of FormValidatable interface * Returns the focused schema for form generation */ getFormSchema(): z.ZodObject<z.ZodRawShape> { const focusedSchema = extendZodSchema( z.object({ name: z .string() .min(1, 'Name is required') .describe( 'Display name for the NFT (e.g., "Sunset Landscape #42", "Digital Abstract Art")' ), description: z .string() .min(1, 'Description is required') .describe( 'Meaningful description of the artwork, story, or concept behind this NFT' ), creator: z .string() .min(1, 'Creator is required') .describe( 'Creator account ID, artist name, or brand (e.g., "0.0.123456", "ArtistName", "StudioBrand")' ), attributes: extendZodSchema( z.array( z.object({ trait_type: z .string() .describe('Trait name (e.g., "Rarity", "Color", "Style")'), value: z .union([z.string(), z.number()]) .describe('Trait value (e.g., "Epic", "Blue", 85)'), }) ) ) .withRender(renderConfigs.array('NFT Attributes', 'Attribute')) .optional() .describe('Collectible traits and characteristics.'), type: z .string() .optional() .describe( 'Category or genre of the NFT (e.g., "Digital Art", "Photography", "Collectible Card)' ), }) ).withRender({ fieldType: 'object', ui: { label: 'Complete NFT Metadata', description: 'Provide meaningful metadata to create a valuable NFT', }, }); return focusedSchema as unknown as z.ZodObject<z.ZodRawShape>; } protected async executeQuery( params: z.infer<typeof inscribeHashinalSchema>, _runManager?: CallbackManagerForToolRun ): Promise<InscriptionResponse> { if (!params.url && !params.contentRef && !params.base64Data) { return createInscriptionError({ code: 'MISSING_CONTENT', details: 'No content source provided', suggestions: [ 'Provide a URL to content you want to inscribe', 'Upload a file and use the content reference', 'Provide base64-encoded content data', ], }); } const operatorAccount = this.inscriberBuilder[ 'hederaKit' ]?.client?.operatorAccountId?.toString() || '0.0.unknown'; const rawMetadata = { ...generateDefaultMetadata({ name: params.name, creator: params.creator, description: params.description, type: params.type, fileName: params.fileName, mimeType: params.mimeType, operatorAccount, }), attributes: Array.isArray(params.attributes) ? params.attributes : [], properties: (params.properties as Record<string, unknown> | undefined) || {}, }; let validatedMetadata; try { validatedMetadata = validateHIP412Metadata(rawMetadata); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return createInscriptionError({ code: 'METADATA_VALIDATION_FAILED', details: `Metadata validation error: ${errorMessage}`, suggestions: [ 'Ensure all required metadata fields are provided', 'Check that attribute values are valid', 'Verify metadata follows HIP-412 standard', ], }); } const options: InscriptionOptions = { mode: 'hashinal', metadata: validatedMetadata, jsonFileURL: params.jsonFileURL, fileStandard: params.fileStandard, tags: params.tags, chunkSize: params.chunkSize, waitForConfirmation: params.quoteOnly ? false : params.waitForConfirmation ?? true, waitMaxAttempts: 60, waitIntervalMs: 5000, network: this.inscriberBuilder['hederaKit'].client.network .toString() .includes('mainnet') ? 'mainnet' : 'testnet', quoteOnly: params.quoteOnly, }; let inscriptionData: InscriptionInput; if (params.url) { inscriptionData = { type: 'url', url: params.url }; } else if (params.contentRef || params.base64Data) { const inputData = params.contentRef || params.base64Data || ''; const { buffer, mimeType, fileName } = await this.resolveContent( inputData, params.mimeType, params.fileName ); inscriptionData = { type: 'buffer' as const, buffer, fileName: fileName || params.fileName || 'hashinal-content', mimeType: mimeType || params.mimeType, }; } else { throw new Error('No valid input data provided for inscription'); } if (params.quoteOnly) { try { const quote = await this.generateInscriptionQuote( inscriptionData, options ); return createInscriptionQuote({ totalCostHbar: quote.totalCostHbar, validUntil: quote.validUntil, breakdown: quote.breakdown, content: { name: params.name, creator: params.creator, type: params.type, }, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to generate inscription quote'; return createInscriptionError({ code: 'QUOTE_GENERATION_FAILED', details: `Quote generation failed: ${errorMessage}`, suggestions: [ 'Check network connectivity', 'Verify content is accessible', 'Try again in a moment', ], }); } } try { let result: Awaited<ReturnType<typeof this.inscriberBuilder.inscribe>>; if (params.timeoutMs) { const timeoutPromise = new Promise<never>((_, reject) => { setTimeout( () => reject( new Error(`Inscription timed out after ${params.timeoutMs}ms`) ), params.timeoutMs ); }); result = await Promise.race([ this.inscriberBuilder.inscribeAuto ? this.inscriberBuilder.inscribeAuto(inscriptionData, options) : this.inscriberBuilder.inscribe(inscriptionData, options), timeoutPromise, ]); } else { result = this.inscriberBuilder.inscribeAuto ? await this.inscriberBuilder.inscribeAuto(inscriptionData, options) : await this.inscriberBuilder.inscribe(inscriptionData, options); } if (result.confirmed && !result.quote) { const ids = extractTopicIds(result.inscription, result.result); const network = (options.network || 'testnet') as 'mainnet' | 'testnet'; const fileStandard = params.fileStandard || '1'; const { hrl, topicId, cdnUrl } = buildInscriptionLinks( ids, network, fileStandard ); const txId = result?.inscription?.tx_id || (result?.result as InscriptionResult)?.transactionId || 'unknown'; const successResponse = createInscriptionSuccess({ hrl: hrl || 'hcs://1/unknown', topicId: topicId || 'unknown', standard: fileStandard === '6' ? 'Dynamic' : 'Static', cdnUrl, transactionId: txId, metadata: { name: params.name, creator: params.creator, description: params.description, type: params.type, attributes: Array.isArray(params.attributes) ? params.attributes : [], }, }); if (params.withHashLinkBlocks !== false) { try { const block = await this.createHashLinkBlock(successResponse); successResponse.hashLinkBlock = block; } catch (e) { const logger = new Logger({ module: 'InscribeHashinalTool' }); logger.warn('Failed to create HashLink block', e); } } return successResponse; } else if (!result.quote && !result.confirmed) { const txId = (result.result as InscriptionResult)?.transactionId || result?.inscription?.transactionId || 'unknown'; return createInscriptionPending({ transactionId: txId, details: 'Successfully submitted Hashinal inscription. Waiting for network confirmation...', }); } else { return createInscriptionError({ code: 'UNEXPECTED_RESULT', details: 'Received an unexpected inscription result state', suggestions: ['Try again or verify network status'], }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to inscribe Hashinal NFT'; throw new Error(`Inscription failed: ${errorMessage}`); } } /** * Creates HashLink block configuration for Hashinal inscriptions. * Automatically detects network and selects appropriate block ID configuration. * Uses testnet block as fallback for unknown networks or undeployed mainnet blocks. * * @param response The inscription response containing metadata and network information * @param _mimeType Optional MIME type (currently unused, preserved for compatibility) * @returns HCS12BlockResult with network-specific block configuration * * @example * ```typescript * // Testnet usage (automatic detection from client) * const testnetClient = Client.forTestnet(); * const tool = new InscribeHashinalTool(testnetClient); * const block = await tool.createHashLinkBlock(inscriptionResponse); * const id = block.blockId; // '0.0.6617393' * const link = block.hashLink; // 'hcs://12/0.0.6617393' * * // Mainnet usage (automatic detection from client) * const mainnetClient = Client.forMainnet(); * const tool = new InscribeHashinalTool(mainnetClient); * const block = await tool.createHashLinkBlock(inscriptionResponse); * const mainnetId = block.blockId; // Network-specific mainnet block ID * * // HashLink Block Response Structure: * { * blockId: string; // Hedera account ID format (e.g., '0.0.6617393') * hashLink: string; // HCS-12 URL format: 'hcs://12/{blockId}' * template: string; // Block template reference matching blockId * attributes: { // Metadata for client-side processing * name: string; // Content display name * creator: string; // Creator account ID * topicId: string; // HCS topic containing the inscription * hrl: string; // Hedera Resource Locator * network: string; // Network type: 'testnet' | 'mainnet' * } * } * * // Render function usage in HashLink blocks: * // The block's JavaScript render function receives this structure * // and can access network-specific resources through attributes.network * ``` */ private async createHashLinkBlock( response: ReturnType<typeof createInscriptionSuccess>, _mimeType?: string ): Promise<{ blockId: string; hashLink: string; template: string; attributes: Record<string, unknown>; }> { const clientNetwork = this.inscriberBuilder['hederaKit'].client.network .toString() .includes('mainnet') ? 'mainnet' : 'testnet'; const cdnNetwork = response.inscription.cdnUrl?.includes('mainnet') ? 'mainnet' : 'testnet'; if (clientNetwork !== cdnNetwork) { const logger = new Logger({ module: 'InscribeHashinalTool' }); logger.warn( `Network mismatch detected: client=${clientNetwork}, cdn=${cdnNetwork}. Using client network.` ); } const network = clientNetwork; const config = getHashLinkBlockId(network); return { blockId: config.blockId, hashLink: config.hashLink, template: config.template, attributes: { name: response.metadata.name || 'Untitled Content', creator: response.metadata.creator || '', topicId: response.inscription.topicId, hrl: response.inscription.hrl, network: network, }, }; } private async resolveContent( input: string, providedMimeType?: string, providedFileName?: string ): Promise<{ buffer: Buffer; mimeType?: string; fileName?: string; wasReference?: boolean; }> { const trimmedInput = input.trim(); const resolver = this.getContentResolver() || ContentResolverRegistry.getResolver(); if (!resolver) { return this.handleDirectContent( trimmedInput, providedMimeType, providedFileName ); } const referenceId = resolver.extractReferenceId(trimmedInput); if (referenceId) { try { const resolution = await resolver.resolveReference(referenceId); return { buffer: resolution.content, mimeType: resolution.metadata?.mimeType || providedMimeType, fileName: resolution.metadata?.fileName || providedFileName, wasReference: true, }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error resolving reference'; throw new Error(`Reference resolution failed: ${errorMsg}`); } } return this.handleDirectContent( trimmedInput, providedMimeType, providedFileName ); } private handleDirectContent( input: string, providedMimeType?: string, providedFileName?: string ): { buffer: Buffer; mimeType?: string; fileName?: string; wasReference?: boolean; } { const isValidBase64 = /^[A-Za-z0-9+/]*={0,2}$/.test(input); if (isValidBase64) { try { const buffer = Buffer.from(input, 'base64'); return { buffer, mimeType: providedMimeType, fileName: providedFileName, wasReference: false, }; } catch (error) { throw new Error( 'Failed to decode base64 data. Please ensure the data is properly encoded.' ); } } const buffer = Buffer.from(input, 'utf8'); return { buffer, mimeType: providedMimeType || 'text/plain', fileName: providedFileName, wasReference: false, }; } /** * Implementation of FormValidatable interface * Returns essential fields that should always be shown in forms */ getEssentialFields(): string[] { return ['name', 'description', 'creator', 'attributes']; } /** * Implementation of FormValidatable interface * Determines if a field value should be considered empty for this tool */ isFieldEmpty(fieldName: string, value: unknown): boolean { if (value === undefined || value === null || value === '') { return true; } if (Array.isArray(value) && value.length === 0) { return true; } if (fieldName === 'attributes' && Array.isArray(value)) { return value.every( (attr) => !attr || (typeof attr === 'object' && (!attr.trait_type || !attr.value)) ); } return false; } }