UNPKG

@hashgraphonline/conversational-agent

Version:

Hashgraph Online conversational AI agent implementing HCS-10 communication, HCS-2 registries, and content inscription on Hedera. https://hol.org

1,339 lines (1,189 loc) 41 kB
import { AgentExecutor } from 'langchain/agents'; import { ZodError, z } from 'zod'; import { FormGenerator } from '../forms/form-generator'; import type { FormMessage, FormSubmission } from '../forms/types'; import { FormEngine } from '../forms/form-engine'; import { Logger } from '@hashgraphonline/standards-sdk'; import type { AgentAction, AgentFinish, AgentStep } from 'langchain/agents'; import type { ToolInterface } from '@langchain/core/tools'; import { isFormValidatable } from '@hashgraphonline/standards-agent-kit'; import type { ChainValues } from '@langchain/core/utils/types'; import type { CallbackManagerForChainRun } from '@langchain/core/callbacks/manager'; import type { RunnableConfig } from '@langchain/core/runnables'; import { ResponseFormatter } from '../utils/response-formatter'; type BasicFieldType = 'text' | 'number' | 'select' | 'checkbox' | 'textarea'; const globalPendingForms: Map<string, PendingFormData> = new Map(); interface HashLinkBlock { blockId: string; hashLink: string; template: string; attributes: Record<string, unknown>; } interface HashLinkResponse { hasHashLinkBlocks: boolean; hashLinkBlock?: HashLinkBlock; message: string; } interface ToolWithOriginal { originalTool?: { call?: (args: Record<string, unknown>) => Promise<string>; }; } interface ActionWithToolInput { toolInput?: Record<string, unknown>; } interface ZodSchemaDefinition { _def?: { typeName?: string; shape?: (() => Record<string, z.ZodTypeAny>) | Record<string, z.ZodTypeAny>; innerType?: z.ZodTypeAny; defaultValue?: unknown; }; } interface ToolWrapper { executeOriginal?: (args: Record<string, unknown>) => Promise<string>; getOriginalTool?: () => { _call?: (args: Record<string, unknown>) => Promise<string>; call?: (args: Record<string, unknown>) => Promise<string>; }; originalTool?: { _call?: (args: Record<string, unknown>) => Promise<string>; call?: (args: Record<string, unknown>) => Promise<string>; }; call?: (args: Record<string, unknown>) => Promise<string>; } interface CallableTool { _call?: (args: Record<string, unknown>) => Promise<string>; call?: (args: Record<string, unknown>) => Promise<string>; } interface ToolWithSchema { schema?: Record<string, unknown>; } interface PendingFormData { toolName: string; originalInput: unknown; originalToolInput?: unknown; schema: unknown; toolRef?: ToolInterface | undefined; originalToolRef?: ToolWithOriginal['originalTool']; } interface ToolResponse { requiresForm?: boolean; formMessage?: { id: string; [key: string]: unknown; }; message?: string; hashLinkBlock?: HashLinkBlock; success?: boolean; inscription?: Record<string, unknown>; metadata?: Record<string, unknown>; } interface IntermediateStepData { action?: { tool?: string; [key: string]: unknown; }; observation?: unknown; } interface HashLinkBlock { blockId: string; hashLink: string; template: string; attributes: Record<string, unknown>; } interface ResponseMetadataWithHashLink { hashLinkBlock?: HashLinkBlock; [key: string]: unknown; } /** * Parameter preprocessing callback interface */ export interface ParameterPreprocessingCallback { (toolName: string, parameters: Record<string, unknown>): Promise< Record<string, unknown> >; } /** * Agent executor that intercepts Zod validation errors and generates forms, * and processes HashLink block responses for rich UI rendering */ export class FormAwareAgentExecutor extends AgentExecutor { private formGenerator: FormGenerator; private formEngine: FormEngine; private formLogger: Logger; private pendingForms: Map<string, PendingFormData> = new Map(); private parameterPreprocessingCallback: | ParameterPreprocessingCallback | undefined; /** * Type guard to check if a Zod type is a ZodObject */ private isZodObject(schema: unknown): schema is z.ZodObject<z.ZodRawShape> { return schema instanceof z.ZodObject; } /** * Type guard to check if metadata has hashLinkBlock */ private hasHashLinkBlock( metadata: unknown ): metadata is ResponseMetadataWithHashLink & { hashLinkBlock: HashLinkBlock; } { return ( typeof metadata === 'object' && metadata !== null && 'hashLinkBlock' in metadata && typeof (metadata as Record<string, unknown>).hashLinkBlock === 'object' && (metadata as Record<string, unknown>).hashLinkBlock !== null ); } constructor(...args: ConstructorParameters<typeof AgentExecutor>) { super(...args); this.formGenerator = new FormGenerator(); this.formEngine = new FormEngine( new Logger({ module: 'FormAwareAgentExecutor.FormEngine' }) ); this.formLogger = new Logger({ module: 'FormAwareAgentExecutor' }); this.parameterPreprocessingCallback = undefined; } /** * Set parameter preprocessing callback */ setParameterPreprocessingCallback( callback: ParameterPreprocessingCallback | undefined ): void { this.parameterPreprocessingCallback = callback; } /** * BULLETPROOF TOOL INTERCEPTION * Override the single-step execution to intercept tool calls BEFORE LangChain processes them */ override async _takeNextStep( nameToolMap: Record<string, ToolInterface>, inputs: ChainValues, intermediateSteps: AgentStep[], runManager?: CallbackManagerForChainRun, config?: RunnableConfig ): Promise<AgentFinish | AgentStep[]> { this.formLogger.info('🛡️ BULLETPROOF INTERCEPTION: _takeNextStep called', { availableTools: Object.keys(nameToolMap), inputKeys: Object.keys(inputs), }); const result = await this.agent.plan( intermediateSteps, inputs, runManager?.getChild() ); if ('returnValues' in result) { this.formLogger.info('Agent returned finish action, passing through'); return result; } const action = result as AgentAction; const toolName = action.tool; const toolInput = action.toolInput; this.formLogger.info(`🎯 INTERCEPTING TOOL CALL: ${toolName}`, { toolInput, hasInNameToolMap: toolName in nameToolMap, toolInputKeys: Object.keys(toolInput || {}), }); const tool = nameToolMap[toolName] || this.tools.find((t) => t.name === toolName); if (!tool) { this.formLogger.error(`Tool ${toolName} not found in registry`); throw new Error(`Tool "${toolName}" not found`); } let shouldGenerateForm = false; if (isFormValidatable(tool)) { this.formLogger.info( `🔍 Tool ${toolName} implements FormValidatable, checking shouldGenerateForm()`, { toolInput, } ); try { shouldGenerateForm = tool.shouldGenerateForm(toolInput); this.formLogger.info( `FormValidatable.shouldGenerateForm() result: ${shouldGenerateForm}`, { toolName, toolInput, } ); } catch (error) { this.formLogger.error( `Error calling shouldGenerateForm() on ${toolName}:`, error ); shouldGenerateForm = false; } } if (shouldGenerateForm) { this.formLogger.info(`🚨 FORM GENERATION TRIGGERED for ${toolName}`); try { let schemaToUse: z.ZodSchema; let isFocusedSchema = false; if (isFormValidatable(tool)) { this.formLogger.info( `🎯 Tool ${toolName} is FormValidatable, attempting to get focused schema` ); try { const focusedSchema = tool.getFormSchema(); if (focusedSchema) { schemaToUse = focusedSchema; isFocusedSchema = true; this.formLogger.info( `✅ Successfully obtained focused schema for ${toolName}` ); } else { this.formLogger.warn( `getFormSchema() returned null/undefined for ${toolName}, using default schema` ); schemaToUse = tool.schema; isFocusedSchema = false; } } catch (error) { this.formLogger.error( `Failed to get focused schema from ${toolName}:`, error ); this.formLogger.info( `Falling back to default schema for ${toolName}` ); schemaToUse = tool.schema; isFocusedSchema = false; } } else { this.formLogger.info( `Tool ${toolName} is not FormValidatable, using default schema` ); schemaToUse = tool.schema; isFocusedSchema = false; } let schemaFieldCount = 'unknown'; try { if (this.isZodObject(schemaToUse)) { const zodObject = schemaToUse as z.ZodObject<z.ZodRawShape>; const shape = zodObject.shape; if (shape && typeof shape === 'object') { schemaFieldCount = Object.keys(shape).length.toString(); } } } catch {} this.formLogger.info( `📋 Generating form with ${ isFocusedSchema ? 'FOCUSED' : 'DEFAULT' } schema`, { toolName, schemaType: schemaToUse?.constructor?.name, estimatedFieldCount: schemaFieldCount, isFocusedSchema, } ); let missingFields: Set<string> | undefined; if (isFocusedSchema) { this.formLogger.info( `⭐ Using focused schema - letting FormGenerator determine fields from schema` ); missingFields = undefined; } else { missingFields = new Set<string>(); if (this.isZodObject(schemaToUse)) { const zodObject = schemaToUse as z.ZodObject<z.ZodRawShape>; const shape = zodObject.shape || {}; for (const fieldName of Object.keys(shape)) { const value = (toolInput || {})[fieldName]; const isEmpty = isFormValidatable(tool) && tool.isFieldEmpty ? tool.isFieldEmpty(fieldName, value) : value === undefined || value === '' || value === null || (Array.isArray(value) && value.length === 0); const isRequired = this.isFieldRequired(schemaToUse, fieldName); const isEssential = isFormValidatable(tool) && tool.getEssentialFields ? tool.getEssentialFields().includes(fieldName) : false; this.formLogger.info(`🔍 Field analysis: ${fieldName}`, { value: value, isEmpty: isEmpty, isRequired: isRequired, isEssential: isEssential, willAddToMissingFields: isEmpty && (isRequired || isEssential), }); if (isEmpty && (isRequired || isEssential)) { missingFields.add(fieldName); } } } this.formLogger.info(`📋 Missing fields analysis complete`, { totalFields: this.isZodObject(schemaToUse) ? Object.keys(schemaToUse.shape).length : 0, missingFieldsCount: missingFields.size, missingFields: Array.from(missingFields), }); } const formMessage = await this.formGenerator.generateFormFromSchema( schemaToUse, toolInput, { toolName: toolName, toolDescription: tool.description, }, missingFields ); if (this.isZodObject(schemaToUse)) { try { const { jsonSchema, uiSchema } = this.formGenerator.generateJsonSchemaForm( schemaToUse, toolInput as Record<string, unknown> | undefined, missingFields ); formMessage.jsonSchema = jsonSchema; formMessage.uiSchema = uiSchema; } catch (error) { this.formLogger.warn( 'Failed to generate JSON Schema for RJSF:', error ); } } formMessage.partialInput = toolInput; const formData: PendingFormData = { toolName: toolName, originalInput: inputs, originalToolInput: toolInput, schema: schemaToUse, toolRef: tool as ToolInterface | undefined, originalToolRef: (tool as ToolWithOriginal).originalTool, }; this.pendingForms.set(formMessage.id, formData); globalPendingForms.set(formMessage.id, formData); this.formLogger.info(`✅ FORM INTERCEPT SUCCESS for ${toolName}`); const formResult = { requiresForm: true, formMessage, }; return [ { action: action, observation: JSON.stringify(formResult), }, ]; } catch (error) { this.formLogger.error(`Form generation failed for ${toolName}:`, error); } } this.formLogger.info( `⚪ Passing through to normal tool execution for ${toolName}` ); if (this.parameterPreprocessingCallback && toolInput) { this.formLogger.info( `🔄 Applying parameter preprocessing for ${toolName}` ); try { const preprocessedInput = await this.parameterPreprocessingCallback( toolName, toolInput as Record<string, unknown> ); if ( preprocessedInput && typeof preprocessedInput === 'object' && '__requestForm' in (preprocessedInput as Record<string, unknown>) ) { const rf = (preprocessedInput as Record<string, unknown>) .__requestForm as { id?: string; title?: string; description?: string; fields?: Array<{ name: string; label: string; type: string; required?: boolean; options?: Array<{ value: string; label: string }>; }>; submitLabel?: string; }; const formId = rf.id || `form_${Date.now()}_${Math.random().toString(36).slice(2)}`; const formMessage = { type: 'form', id: formId, originalPrompt: 'Parameter validation required', toolName, formConfig: { title: rf.title || 'Complete required parameters', description: rf.description || 'One or more parameters require confirmation. Please review and submit.', submitLabel: rf.submitLabel || 'Continue', fields: (rf.fields || []).map((f) => { const allowedTypes: BasicFieldType[] = [ 'text', 'number', 'select', 'checkbox', 'textarea', ]; const resolvedType: BasicFieldType = allowedTypes.includes( f.type as BasicFieldType ) ? (f.type as BasicFieldType) : 'text'; return { name: f.name, label: f.label, type: resolvedType, required: f.required ?? true, options: f.options, }; }), }, }; const resolvedSchema = isFormValidatable(tool) ? (() => { try { const s = tool.getFormSchema(); return s || tool.schema; } catch { return tool.schema; } })() : tool.schema; this.pendingForms.set(formId, { toolName, originalInput: inputs, originalToolInput: toolInput, schema: resolvedSchema, toolRef: tool as ToolInterface | undefined, originalToolRef: (tool as ToolWithOriginal).originalTool, }); globalPendingForms.set(formId, { toolName, originalInput: inputs, originalToolInput: toolInput, schema: resolvedSchema, }); return [ { action, observation: JSON.stringify({ requiresForm: true, formMessage }), }, ]; } if (JSON.stringify(preprocessedInput) !== JSON.stringify(toolInput)) { this.formLogger.info(`📝 Parameters preprocessed for ${toolName}:`, { original: Object.keys(toolInput as Record<string, unknown>), preprocessed: Object.keys(preprocessedInput), hasChanges: true, }); try { (action as ActionWithToolInput).toolInput = preprocessedInput; } catch {} } else { this.formLogger.debug(`No parameter changes needed for ${toolName}`); } } catch (preprocessError) { this.formLogger.warn( `Parameter preprocessing failed for ${toolName}, using original parameters:`, preprocessError ); } } return super._takeNextStep( nameToolMap, inputs, intermediateSteps, runManager, config ); } /** * Helper to determine if a field is required in the schema */ private isFieldRequired(schema: unknown, fieldPath: string): boolean { if (!schema || !fieldPath) { return false; } try { const obj = schema as ZodSchemaDefinition; const def = obj._def; if (!def || def.typeName !== 'ZodObject') { return false; } const rawShape: unknown = typeof def.shape === 'function' ? def.shape() : def.shape; if (!rawShape || typeof rawShape !== 'object') { return false; } const shape = rawShape as Record<string, z.ZodTypeAny>; const fieldSchema = shape[fieldPath]; if (!fieldSchema) { return false; } const unwrapOptional = (s: z.ZodTypeAny): z.ZodTypeAny => { const inner = (s as ZodSchemaDefinition)._def; if (inner && inner.typeName === 'ZodOptional' && inner.innerType) { return inner.innerType; } return s; }; const unwrapped = unwrapOptional(fieldSchema); const fdef = (unwrapped as ZodSchemaDefinition)._def; if (!fdef) { return true; } if (fdef.typeName === 'ZodOptional' || fdef.typeName === 'ZodDefault') { return false; } if (fdef.defaultValue !== undefined) { return false; } return true; } catch (error) { this.formLogger.debug( `Could not determine if field ${fieldPath} is required:`, error ); } return false; } /** * Override _call to intercept Zod validation errors at the execution level */ override async _call( inputs: Record<string, unknown> ): Promise<Record<string, unknown>> { try { const result = await super._call(inputs); if (result.intermediateSteps && Array.isArray(result.intermediateSteps)) { for (const step of result.intermediateSteps) { if (step.observation) { try { const parsed: ToolResponse = typeof step.observation === 'string' ? JSON.parse(step.observation) : (step.observation as ToolResponse); if (parsed.requiresForm && parsed.formMessage) { this.formLogger.info('Tool requested form generation', { toolName: step.action?.tool, hasForm: true, }); const actionToolName = (step as IntermediateStepData).action?.tool || 'unknown'; const toolInstance = this.tools.find( (t) => t.name === actionToolName ) as ToolInterface | undefined; const originalToolCandidate = (toolInstance as ToolWithOriginal) || {}; const pf: PendingFormData = { toolName: actionToolName, originalInput: inputs, originalToolInput: (step as IntermediateStepData).action ?.toolInput as Record<string, unknown> | undefined, schema: null, toolRef: toolInstance, originalToolRef: originalToolCandidate?.originalTool, }; this.pendingForms.set(parsed.formMessage.id, pf); globalPendingForms.set(parsed.formMessage.id, pf); return { ...result, requiresForm: true, formMessage: parsed.formMessage, output: parsed.message || 'Please complete the form to continue.', }; } if ( parsed.hashLinkBlock || (parsed.success && parsed.inscription && parsed.hashLinkBlock) ) { this.formLogger.info('Tool returned HashLink blocks', { toolName: (step as IntermediateStepData).action?.tool, hasHashLink: true, blockId: parsed.hashLinkBlock?.blockId, }); const hashLinkResponse = this.processHashLinkResponse(parsed); return { ...result, hasHashLinkBlocks: true, hashLinkBlock: hashLinkResponse.hashLinkBlock, output: hashLinkResponse.message, }; } } catch {} } } } return result; } catch (error) { if (error instanceof ZodError) { this.formLogger.info('Intercepted ZodError during agent execution'); return this.handleValidationError(error, inputs, []); } throw error; } } /** * Handles Zod validation errors by generating forms */ private async handleValidationError( error: ZodError, inputs: Record<string, unknown>, intermediateSteps: AgentStep[] ): Promise<Record<string, unknown>> { this.formLogger.info('Zod validation error detected, generating form', { errorIssues: error.issues.length, inputKeys: Object.keys(inputs), }); let toolInfo = this.extractToolInfoFromError( error, inputs, intermediateSteps ); if (!toolInfo) { this.formLogger.warn( 'Could not extract tool info from validation error, trying fallback detection' ); const fallbackTool = this.detectToolFromErrorContext(error); if (!fallbackTool) { this.formLogger.error( 'No tool detected for form generation, rethrowing error' ); throw error; } toolInfo = fallbackTool; } this.formLogger.info('Generating form for tool:', { toolName: toolInfo.toolName, hasSchema: !!toolInfo.schema, }); const formMessage = this.formGenerator.generateFormFromError( error, toolInfo.schema as z.ZodType<unknown, z.ZodTypeDef, unknown>, toolInfo.toolName, (inputs.input as string) || '' ); this.pendingForms.set(formMessage.id, { toolName: toolInfo.toolName, originalInput: inputs, schema: toolInfo.schema, }); globalPendingForms.set(formMessage.id, { toolName: toolInfo.toolName, originalInput: inputs, schema: toolInfo.schema, }); return { output: this.formatFormResponse(formMessage), formMessage, requiresForm: true, intermediateSteps: intermediateSteps || [], }; } /** * Get a copy of pending forms for preservation during executor recreation */ getPendingForms(): Map<string, PendingFormData> { return new Map(this.pendingForms); } /** * Restore pending forms from a previous executor instance */ restorePendingForms(forms: Map<string, PendingFormData>): void { for (const [formId, formData] of forms) { this.pendingForms.set(formId, formData); } } /** * Processes form submission and continues tool execution */ async processFormSubmission( submission: FormSubmission ): Promise<Record<string, unknown>> { this.formLogger.info( '🚀 FormAwareAgentExecutor.processFormSubmission called!', { submissionFormId: submission.formId, submissionToolName: submission.toolName, } ); if (!submission) { throw new Error('Form submission is null or undefined'); } if (!submission.formId) { throw new Error('Form submission missing formId'); } if ( !submission.parameters || submission.parameters === null || typeof submission.parameters !== 'object' || Array.isArray(submission.parameters) ) { throw new Error( `Form submission parameters are invalid: ${typeof submission.parameters}, isNull: ${ submission.parameters === null }, isArray: ${Array.isArray( submission.parameters )}, parameters: ${JSON.stringify(submission.parameters)}` ); } this.formLogger.info('Processing form submission:', { formId: submission.formId, toolName: submission.toolName, parameterKeys: Object.keys(submission.parameters), parametersType: typeof submission.parameters, parametersIsNull: submission.parameters === null, parametersIsUndefined: submission.parameters === undefined, hasContext: !!submission.context, }); let pendingForm = this.pendingForms.get(submission.formId); if (!pendingForm) { pendingForm = globalPendingForms.get(submission.formId); if (!pendingForm) { throw new Error(`No pending form found for ID: ${submission.formId}`); } } this.pendingForms.delete(submission.formId); globalPendingForms.delete(submission.formId); const tool = pendingForm.toolRef || this.tools.find((t) => t.name === pendingForm.toolName); if (!tool) { throw new Error( `Tool not found for form submission: ${pendingForm.toolName}` ); } let baseToolInput: Record<string, unknown> = {}; try { if ( pendingForm.originalToolInput && typeof pendingForm.originalToolInput === 'object' ) { baseToolInput = { ...(pendingForm.originalToolInput as Record<string, unknown>), }; } } catch (error) { this.formLogger.warn( 'Failed to extract base tool input, using empty object:', error ); baseToolInput = {}; } let submissionData: Record<string, unknown> = {}; try { if (submission.parameters && typeof submission.parameters === 'object') { submissionData = { ...(submission.parameters as Record<string, unknown>), }; } } catch (error) { this.formLogger.warn( 'Failed to extract submission parameters, using empty object:', error ); submissionData = {}; } const mergedToolInput: Record<string, unknown> = {}; try { Object.keys(baseToolInput).forEach((key) => { const value = baseToolInput[key]; if (value !== undefined && value !== null) { mergedToolInput[key] = value; } }); Object.keys(submissionData).forEach((key) => { const value = submissionData[key]; if (value !== undefined && value !== null) { mergedToolInput[key] = value; } }); mergedToolInput.renderForm = false; mergedToolInput.__fromForm = true; this.formLogger.info('Successfully merged tool input:', { baseKeys: Object.keys(baseToolInput), submissionKeys: Object.keys(submissionData), mergedKeys: Object.keys(mergedToolInput), }); } catch (error) { this.formLogger.error('Failed to merge tool input data:', error); throw new Error( `Failed to merge tool input data: ${ error instanceof Error ? error.message : 'Unknown error' }` ); } try { const maybeWrapper = tool as ToolWrapper; let toolOutput: string; if (typeof maybeWrapper.executeOriginal === 'function') { toolOutput = await maybeWrapper.executeOriginal(mergedToolInput); } else if (typeof maybeWrapper.getOriginalTool === 'function') { const ot = maybeWrapper.getOriginalTool(); const otCall = ot as CallableTool; if (ot && typeof otCall._call === 'function') { toolOutput = await otCall._call(mergedToolInput); } else if (ot && typeof otCall.call === 'function') { toolOutput = await otCall.call(mergedToolInput); } else { const tcall = tool as CallableTool; if (typeof tcall.call === 'function') { toolOutput = await tcall.call(mergedToolInput); } else { throw new Error( 'No callable tool implementation found for form submission' ); } } } else if ( maybeWrapper.originalTool && typeof maybeWrapper.originalTool._call === 'function' ) { toolOutput = await maybeWrapper.originalTool._call(mergedToolInput); } else if ( maybeWrapper.originalTool && typeof maybeWrapper.originalTool.call === 'function' ) { toolOutput = await maybeWrapper.originalTool.call(mergedToolInput); } else if (typeof (tool as CallableTool).call === 'function') { toolOutput = await (tool as CallableTool).call!(mergedToolInput); } else { throw new Error( 'No callable tool implementation found for form submission' ); } let responseMetadata: Record<string, unknown> = {}; let formattedOutput: string; try { const parsed = JSON.parse(toolOutput); this.formLogger.info( '✅ METADATA EXTRACTION: Successfully parsed JSON', { jsonKeys: Object.keys(parsed), hasHashLinkBlock: !!(parsed as Record<string, unknown>) .hashLinkBlock, } ); if (parsed && typeof parsed === 'object') { if (ResponseFormatter.isHashLinkResponse(parsed)) { this.formLogger.info( '🔗 HASHLINK DETECTED: Processing HashLink response separately to preserve metadata' ); const parsedRecord = parsed as Record<string, unknown>; responseMetadata = { ...responseMetadata, hashLinkBlock: parsedRecord.hashLinkBlock, ...parsedRecord, }; formattedOutput = ResponseFormatter.formatHashLinkResponse(parsed); this.formLogger.info( '🔗 METADATA PRESERVED: HashLink metadata extracted for component rendering', { blockId: this.hasHashLinkBlock(responseMetadata) ? responseMetadata.hashLinkBlock.blockId : undefined, hasTemplate: this.hasHashLinkBlock(responseMetadata) ? !!responseMetadata.hashLinkBlock.template : false, } ); } else { formattedOutput = ResponseFormatter.formatResponse(toolOutput); responseMetadata = { ...responseMetadata, hashLinkBlock: (parsed as Record<string, unknown>).hashLinkBlock, ...parsed, }; } } else { formattedOutput = ResponseFormatter.formatResponse(toolOutput); } } catch (error) { this.formLogger.warn( '❌ METADATA EXTRACTION: Tool output is not JSON', { error: error instanceof Error ? error.message : 'unknown error', outputSample: typeof toolOutput === 'string' ? toolOutput.substring(0, 200) : 'not-string', } ); formattedOutput = ResponseFormatter.formatResponse(toolOutput); } return { output: formattedOutput, formCompleted: true, originalFormId: submission.formId, intermediateSteps: [], metadata: responseMetadata, rawToolOutput: toolOutput, toolName: pendingForm.toolName, }; } catch (error) { if (error instanceof ZodError) { return this.handleValidationError(error, mergedToolInput, []); } throw error; } } /** * Extracts tool information from the execution context */ private extractToolInfoFromError( error: ZodError, inputs: Record<string, unknown>, intermediateSteps: AgentStep[] ): { toolName: string; schema: unknown } | null { try { if (intermediateSteps.length > 0) { const lastStep = intermediateSteps[intermediateSteps.length - 1]; if (lastStep.action && lastStep.action.tool) { const tool = this.tools.find((t) => t.name === lastStep.action.tool); if (tool && 'schema' in tool) { this.formLogger.info( 'Found tool from intermediate steps:', lastStep.action.tool ); return { toolName: lastStep.action.tool, schema: (tool as ToolWithSchema).schema, }; } } } const inputSteps = (inputs.intermediateSteps as unknown[]) || []; if (inputSteps.length > 0) { const lastStep = inputSteps[inputSteps.length - 1]; let action: AgentAction; if (Array.isArray(lastStep) && lastStep.length > 0) { action = lastStep[0] as AgentAction; } else if ((lastStep as Record<string, unknown>).action) { action = (lastStep as Record<string, unknown>).action as AgentAction; } else { action = lastStep as AgentAction; } if (action && action.tool) { const tool = this.tools.find((t) => t.name === action.tool); if (tool && 'schema' in tool) { this.formLogger.info('Found tool from input steps:', action.tool); return { toolName: action.tool, schema: (tool as ToolWithSchema).schema, }; } } } const toolFromContext = this.findToolFromContext(inputs); if (toolFromContext) { this.formLogger.info( 'Found tool from context:', toolFromContext.toolName ); return toolFromContext; } return null; } catch (err) { this.formLogger.error('Error extracting tool info:', err); return null; } } /** * Attempts to find tool from execution context */ private findToolFromContext(inputs: Record<string, unknown>): { toolName: string; schema: unknown; } | null { const inputText = (inputs.input as string) || ''; for (const tool of this.tools) { const keywords = this.extractToolKeywords(tool.name); if ( keywords.some((keyword) => inputText.toLowerCase().includes(keyword.toLowerCase()) ) ) { if ('schema' in tool) { return { toolName: tool.name, schema: (tool as ToolWithSchema).schema, }; } } } return null; } /** * Additional fallback to detect tool from error context */ private detectToolFromErrorContext( error: ZodError ): { toolName: string; schema: unknown } | null { const errorPaths = error.issues.map((issue) => issue.path.join('.')); for (const tool of this.tools) { if ('schema' in tool) { const toolSchema = (tool as ToolWithSchema).schema; if (this.schemaMatchesErrorPaths(toolSchema, errorPaths)) { this.formLogger.info( 'Detected tool from error path analysis:', tool.name ); return { toolName: tool.name, schema: toolSchema, }; } } } return null; } /** * Checks if a schema structure matches error paths */ private schemaMatchesErrorPaths( schema: unknown, errorPaths: string[] ): boolean { const schemaRecord = schema as Record<string, unknown>; if (!schemaRecord || !schemaRecord._def) return false; try { const def = schemaRecord._def as Record<string, unknown>; if (def.typeName === 'ZodObject') { const shape = def.shape as Record<string, unknown>; const schemaKeys = Object.keys(shape || {}); return errorPaths.some((path) => { const topLevelKey = path.split('.')[0]; return schemaKeys.includes(topLevelKey); }); } } catch (err) { this.formLogger.debug('Error analyzing schema structure:', err); } return false; } /** * Extracts keywords from tool name for matching */ private extractToolKeywords(toolName: string): string[] { const words = toolName .replace(/([A-Z])/g, ' $1') .toLowerCase() .split(/[\s_-]+/) .filter((w) => w.length > 2); return [...words, toolName.toLowerCase()]; } /** * Formats the form message for display */ private formatFormResponse(formMessage: FormMessage): string { const fieldCount = formMessage.formConfig.fields.length; const fieldList = formMessage.formConfig.fields .slice(0, 3) .map((f) => `• ${f.label}`) .join('\n'); return `I need some additional information to complete your request. ${formMessage.formConfig.description} Required fields: ${fieldList}${fieldCount > 3 ? `\n... and ${fieldCount - 3} more` : ''} Please fill out the form below to continue.`; } /** * Check if there are pending forms */ hasPendingForms(): boolean { return this.pendingForms.size > 0; } /** * Get information about pending forms */ getPendingFormsInfo(): Array<{ formId: string; toolName: string }> { return Array.from(this.pendingForms.entries()).map(([formId, info]) => ({ formId, toolName: info.toolName, })); } /** * Processes HashLink block responses from tools */ private processHashLinkResponse( toolResponse: ToolResponse ): HashLinkResponse { try { let hashLinkBlock: HashLinkBlock | undefined; if (toolResponse.hashLinkBlock) { hashLinkBlock = toolResponse.hashLinkBlock; } else if (toolResponse.success && toolResponse.hashLinkBlock) { hashLinkBlock = toolResponse.hashLinkBlock; } if (!hashLinkBlock) { throw new Error('HashLink block data not found in response'); } if ( !hashLinkBlock.blockId || !hashLinkBlock.hashLink || !hashLinkBlock.attributes ) { throw new Error( 'Invalid HashLink block structure - missing required fields' ); } let message = 'Content processed successfully!'; if (toolResponse.success && toolResponse.inscription) { const inscription = toolResponse.inscription; const metadata = toolResponse.metadata || {}; message = `✅ ${inscription.standard} Hashinal inscription completed!\n\n`; if (metadata.name) { message += `**${metadata.name}**\n`; } if (metadata.description) { message += `${metadata.description}\n\n`; } message += `📍 **Topic ID:** ${inscription.topicId}\n`; message += `🔗 **HRL:** ${inscription.hrl}\n`; if (inscription.cdnUrl) { message += `🌐 **CDN URL:** ${inscription.cdnUrl}\n`; } if (metadata.creator) { message += `👤 **Creator:** ${metadata.creator}\n`; } } this.formLogger.info('Processed HashLink response', { blockId: hashLinkBlock.blockId, hashLink: hashLinkBlock.hashLink, hasTemplate: !!hashLinkBlock.template, attributeCount: Object.keys(hashLinkBlock.attributes || {}).length, }); return { hasHashLinkBlocks: true, hashLinkBlock, message, }; } catch (error) { this.formLogger.error('Error processing HashLink response:', error); return { hasHashLinkBlocks: false, message: 'Content processed, but interactive display is not available.', }; } } /** * Get FormEngine statistics for debugging */ getFormEngineStatistics(): { strategies: string[]; middleware: string[]; } { return { strategies: this.formEngine.getRegisteredStrategies(), middleware: this.formEngine.getRegisteredMiddleware(), }; } }