aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/nodes/chatbot/agentsystem/Agent.ts
blob: 6a15d0c1dd1a3c8fe69a26bb7e0b0f5da0ef0865 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
import dotenv from 'dotenv';
import { XMLBuilder, XMLParser } from 'fast-xml-parser';
import { escape } from 'lodash'; // Imported escape from lodash
import OpenAI from 'openai';
import { DocumentOptions } from '../../../../documents/Documents';
import { AnswerParser } from '../response_parsers/AnswerParser';
import { StreamedAnswerParser } from '../response_parsers/StreamedAnswerParser';
import { BaseTool } from '../tools/BaseTool';
import { CalculateTool } from '../tools/CalculateTool';
//import { CreateAnyDocumentTool } from '../tools/CreateAnyDocTool';
import { DataAnalysisTool } from '../tools/DataAnalysisTool';
import { DocumentMetadataTool } from '../tools/DocumentMetadataTool';
import { ImageCreationTool } from '../tools/ImageCreationTool';
import { NoTool } from '../tools/NoTool';
import { SearchTool } from '../tools/SearchTool';
import { Parameter, ParametersType, TypeMap } from '../types/tool_types';
import { AgentMessage, ASSISTANT_ROLE, AssistantMessage, Observation, PROCESSING_TYPE, ProcessingInfo, TEXT_TYPE } from '../types/types';
import { Vectorstore } from '../vectorstore/Vectorstore';
import { getReactPrompt } from './prompts';
//import { DictionaryTool } from '../tools/DictionaryTool';
import { ChatCompletionMessageParam } from 'openai/resources';
import { Upload } from '../../../../../server/SharedMediaTypes';
import { DocumentView } from '../../DocumentView';
import { CodebaseSummarySearchTool } from '../tools/CodebaseSummarySearchTool';
import { CreateLinksTool } from '../tools/CreateLinksTool';
import { CreateNewTool } from '../tools/CreateNewTool';
import { FileContentTool } from '../tools/FileContentTool';
import { FileNamesTool } from '../tools/FileNamesTool';
import { RAGTool } from '../tools/RAGTool';
import { SortDocsTool } from '../tools/SortDocsTool';
import { TagDocsTool } from '../tools/TagDocsTool';
import { TakeQuizTool } from '../tools/TakeQuizTool';
import { GPTTutorialTool } from '../tools/TutorialTool';
import { WebsiteInfoScraperTool } from '../tools/WebsiteInfoScraperTool';
import { AgentDocumentManager } from '../utils/AgentDocumentManager';
import { FilterDocsTool } from '../tools/FilterDocsTool';
import { CanvasDocsTool } from '../tools/CanvasDocsTool';

dotenv.config();

/**
 * The Agent class handles the interaction between the assistant and the tools available,
 * processes user queries, and manages the communication flow between the tools and OpenAI.
 */
export class Agent {
    // Private properties
    private client: OpenAI;
    private messages: AgentMessage[] = [];
    private interMessages: AgentMessage[] = [];
    private vectorstore: Vectorstore;
    private _history: () => string;
    private _csvData: () => { filename: string; id: string; text: string }[];
    private actionNumber: number = 0;
    private thoughtNumber: number = 0;
    private processingNumber: number = 0;
    private processingInfo: ProcessingInfo[] = [];
    private streamedAnswerParser: StreamedAnswerParser = new StreamedAnswerParser();
    private tools: Record<string, BaseTool<ReadonlyArray<Parameter>>>;
    private _docManager: AgentDocumentManager;
    private is_dash_doc_assistant: boolean;
    private parentView: DocumentView;
    // Dynamic tool registry for tools created at runtime
    private dynamicToolRegistry: Map<string, BaseTool<ReadonlyArray<Parameter>>> = new Map();
    // Callback for notifying when tools are created and need reload
    private onToolCreatedCallback?: (toolName: string) => void;
    // Storage for deferred tool saving
    private pendingToolSave?: { toolName: string; completeToolCode: string };

    /**
     * The constructor initializes the agent with the vector store and toolset, and sets up the OpenAI client.
     * @param _vectorstore Vector store instance for document storage and retrieval.
     * @param summaries A function to retrieve document summaries (deprecated, now using docManager directly).
     * @param history A function to retrieve chat history.
     * @param csvData A function to retrieve CSV data linked to the assistant.
     * @param getLinkedUrlDocId A function to get document IDs from URLs.
     * @param createImage A function to create images in the dashboard.
     * @param createCSVInDash A function to create a CSV document in the dashboard.
     * @param docManager The document manager instance.
     */
    constructor(
        _vectorstore: Vectorstore,
        history: () => string,
        csvData: () => { filename: string; id: string; text: string }[],
        createImage: (result: Upload.FileInformation & Upload.InspectionResults, options: DocumentOptions) => void,
        createCSVInDash: (url: string, title: string, id: string, data: string) => void,
        docManager: AgentDocumentManager
    ) {
        // Initialize OpenAI client with API key from environment
        this.client = new OpenAI({ apiKey: process.env.OPENAI_KEY, dangerouslyAllowBrowser: true });
        this.vectorstore = _vectorstore;
        this.parentView = docManager.parentViewDocument; // Get the parent DocumentView
        this._history = history;
        this._csvData = csvData;
        this._docManager = docManager;
        this.is_dash_doc_assistant = true; // Initialize to default value

        // Initialize dynamic tool registry
        this.dynamicToolRegistry = new Map();

        // Define available tools for the assistant
        this.tools = {
            calculate: new CalculateTool(),
            rag: new RAGTool(this.vectorstore),
            dataAnalysis: new DataAnalysisTool(csvData),
            websiteInfoScraper: new WebsiteInfoScraperTool(this._docManager),
            searchTool: new SearchTool(this._docManager),
            noTool: new NoTool(),
            imageCreationTool: new ImageCreationTool(createImage),
            documentMetadata: new DocumentMetadataTool(this._docManager),
            createLinks: new CreateLinksTool(this._docManager),
            codebaseSummarySearch: new CodebaseSummarySearchTool(this.vectorstore),
            fileContent: new FileContentTool(this.vectorstore),
            fileNames: new FileNamesTool(this.vectorstore),
            generateTutorialNode: new GPTTutorialTool(this._docManager),
            sortDocs: new SortDocsTool(this._docManager, this.parentView),
            tagDocs: new TagDocsTool(this._docManager),
            filterDocs: new FilterDocsTool(this._docManager, this.parentView),
            takeQuiz: new TakeQuizTool(this._docManager),
            canvasDocs: new CanvasDocsTool(),

        };

        // Add the createNewTool after other tools are defined
        this.tools.createNewTool = new CreateNewTool(this.dynamicToolRegistry, this.tools, this);

        // Load existing dynamic tools
        this.loadExistingDynamicTools();
    }

    /**
     * Loads every dynamic tool that the server reports via /getDynamicTools.
     *  • Uses dynamic `import()` so webpack/vite will code-split each tool automatically.
     *  • Registers the tool in `dynamicToolRegistry` under the name it advertises via
     *    `toolInfo.name`; also registers the legacy camel-case key if different.
     */
    private async loadExistingDynamicTools(): Promise<void> {
        try {
            console.log('Loading dynamic tools from server…');
            const toolFiles = await this.fetchDynamicToolList();

            let loaded = 0;
            for (const { name: className, path } of toolFiles) {
                // Legacy key (e.g., CharacterCountTool → characterCountTool)
                const legacyKey = className.replace(/^[A-Z]/, m => m.toLowerCase());

                // Skip if we already have the legacy key
                if (this.dynamicToolRegistry.has(legacyKey)) continue;

                try {
                    // @vite-ignore keeps Vite/Webpack from trying to statically analyse the variable part
                    const ToolClass = require(`../tools/${path}`)[className];

                    if (!ToolClass || !(ToolClass.prototype instanceof BaseTool)) {
                        console.warn(`File ${path} does not export a valid BaseTool subclass`);
                        continue;
                    }

                    const instance: BaseTool<ReadonlyArray<Parameter>> = new ToolClass();

                    // Prefer the tool’s self-declared name (matches <action> tag)
                    const key = (instance.name || '').trim() || legacyKey;

                    // Check for duplicates
                    if (this.dynamicToolRegistry.has(key)) {
                        console.warn(`Dynamic tool key '${key}' already registered – keeping existing instance`);
                        continue;
                    }

                    // ✅ register under the preferred key
                    this.dynamicToolRegistry.set(key, instance);

                    // optional: also register the legacy key for safety
                    if (key !== legacyKey && !this.dynamicToolRegistry.has(legacyKey)) {
                        this.dynamicToolRegistry.set(legacyKey, instance);
                    }

                    loaded++;
                    console.info(`✓ Loaded dynamic tool '${key}' from '${path}'`);
                } catch (err) {
                    console.error(`✗ Failed to load '${path}':`, err);
                }
            }

            console.log(`Dynamic-tool load complete – ${loaded}/${toolFiles.length} added`);
        } catch (err) {
            console.error('Dynamic-tool bootstrap failed:', err);
        }
    }

    /**
     * Manually registers a dynamic tool instance (called by CreateNewTool)
     */
    public registerDynamicTool(toolName: string, toolInstance: BaseTool<ReadonlyArray<Parameter>>): void {
        this.dynamicToolRegistry.set(toolName, toolInstance);
        console.log(`Manually registered dynamic tool: ${toolName}`);
    }

    /**
     * Notifies that a tool has been created and saved to disk (called by CreateNewTool)
     */
    public notifyToolCreated(toolName: string, completeToolCode: string): void {
        // Store the tool data for deferred saving
        this.pendingToolSave = { toolName, completeToolCode };

        if (this.onToolCreatedCallback) {
            this.onToolCreatedCallback(toolName);
        }
    }

    /**
     * Performs the deferred tool save operation (called after user confirmation)
     */
    public async performDeferredToolSave(): Promise<boolean> {
        if (!this.pendingToolSave) {
            console.warn('No pending tool save operation');
            return false;
        }

        const { toolName, completeToolCode } = this.pendingToolSave;

        try {
            // Get the CreateNewTool instance to perform the save
            const createNewTool = this.tools.createNewTool as any;
            if (createNewTool && typeof createNewTool.saveToolToServerDeferred === 'function') {
                const success = await createNewTool.saveToolToServerDeferred(toolName, completeToolCode);

                if (success) {
                    console.log(`Tool ${toolName} saved to server successfully via deferred save.`);
                    // Clear the pending save
                    this.pendingToolSave = undefined;
                    return true;
                } else {
                    console.warn(`Tool ${toolName} could not be saved to server via deferred save.`);
                    return false;
                }
            } else {
                console.error('CreateNewTool instance not available for deferred save');
                return false;
            }
        } catch (error) {
            console.error(`Error performing deferred tool save for ${toolName}:`, error);
            return false;
        }
    }

    /**
     * Sets the callback for when tools are created
     */
    public setToolCreatedCallback(callback: (toolName: string) => void): void {
        this.onToolCreatedCallback = callback;
    }

    /**
     * Public method to reload dynamic tools (called when new tools are created)
     */
    public reloadDynamicTools(): void {
        console.log('Reloading dynamic tools...');
        this.loadExistingDynamicTools();
    }

    private async fetchDynamicToolList(): Promise<{ name: string; path: string }[]> {
        const res = await fetch('/getDynamicTools');
        if (!res.ok) throw new Error(`Failed to fetch dynamic tool list – ${res.statusText}`);
        const json = await res.json();
        console.log('Dynamic tools fetched:', json.tools);
        return json.tools ?? [];
    }

    /**
     * This method handles the conversation flow with the assistant, processes user queries,
     * and manages the assistant's decision-making process, including tool actions.
     * @param question The user's question.
     * @param onProcessingUpdate Callback function for processing updates.
     * @param onAnswerUpdate Callback function for answer updates.
     * @param maxTurns The maximum number of turns to allow in the conversation.
     * @returns The final response from the assistant.
     */
    async askAgent(question: string, onProcessingUpdate: (processingUpdate: ProcessingInfo[]) => void, onAnswerUpdate: (answerUpdate: string) => void, maxTurns: number = 50): Promise<AssistantMessage> {
        console.log(`Starting query: ${question}`);
        const MAX_QUERY_LENGTH = 1000; // adjust the limit as needed

        // Check if the question exceeds the maximum length
        if (question.length > MAX_QUERY_LENGTH) {
            const errorText = `Your query is too long (${question.length} characters). Please shorten it to ${MAX_QUERY_LENGTH} characters or less and try again.`;
            console.warn(errorText); // Log the specific reason
            return {
                role: ASSISTANT_ROLE.ASSISTANT,
                // Use ERROR type for clarity in the UI if handled differently
                content: [{ text: errorText, index: 0, type: TEXT_TYPE.ERROR, citation_ids: null }],
                processing_info: [],
            };
        }

        const sanitizedQuestion = escape(question); // Sanitized user input

        // Push sanitized user's question to message history
        this.messages.push({ role: 'user', content: sanitizedQuestion });

        // Get system prompt with all tools (static + dynamic)
        const systemPrompt = this.getSystemPromptWithAllTools();

        // Initialize intermediate messages
        this.interMessages = [{ role: 'system', content: systemPrompt }];

        this.interMessages.push({
            role: 'user',
            content: this.constructUserPrompt(1, 'user', `<query>${sanitizedQuestion}</query>`),
        });

        // Setup XML parser and builder
        const parser = new XMLParser({
            ignoreAttributes: false,
            attributeNamePrefix: '@_',
            textNodeName: '_text',
            isArray: name => ['query', 'url'].indexOf(name) !== -1,
            processEntities: false, // Disable processing of entities
            stopNodes: ['*.entity'], // Do not process any entities
        });
        const builder = new XMLBuilder({ ignoreAttributes: false, attributeNamePrefix: '@_' });

        let currentAction: string | undefined;
        this.processingInfo = [];

        let i = 2;
        while (i < maxTurns) {
            console.log(this.interMessages);
            console.log(`Turn ${i}/${maxTurns}`);

            // eslint-disable-next-line no-await-in-loop
            const result = await this.execute(onProcessingUpdate, onAnswerUpdate);
            this.interMessages.push({ role: 'assistant', content: result });

            i += 2;

            let parsedResult;
            try {
                // Parse XML result from the assistant
                parsedResult = parser.parse(result);

                // Validate the structure of the parsedResult
                this.validateAssistantResponse(parsedResult);
            } catch (error) {
                throw new Error(`Error parsing or validating response: ${error}`);
            }

            // Extract the stage from the parsed result
            const stage = parsedResult.stage;
            if (!stage) {
                throw new Error(`Error: No stage found in response`);
            }

            // Handle different stage elements (thoughts, actions, inputs, answers)
            for (const key in stage) {
                if (key === 'thought') {
                    // Handle assistant's thoughts
                    console.log(`Thought: ${stage[key]}`);
                    this.processingNumber++;
                } else if (key === 'action') {
                    // Handle action stage
                    currentAction = stage[key] as string;
                    console.log(`Action: ${currentAction}`);

                    // Check both static tools and dynamic registry
                    const tool = this.tools[currentAction] || this.dynamicToolRegistry.get(currentAction);
                    if (currentAction === 'noTool') {
                        // Immediately ask for clarification in plain text, not as a tool prompt
                        this.interMessages.push({
                            role: 'user',
                            content: `<stage number="${i + 1}" role="assistant">
                                        <answer>
                                        I’m not sure what you’d like me to do.  Could you clarify your request?
                                        </answer>
                                    </stage>`,
                        });
                        break;
                    }
                    if (tool) {
                        // Prepare the next action based on the current tool
                        const nextPrompt = [
                            {
                                type: 'text',
                                text: `<stage number="${i + 1}" role="user">` + builder.build({ action_rules: tool.getActionRule() }) + `</stage>`,
                            } as Observation,
                        ];
                        this.interMessages.push({ role: 'user', content: nextPrompt });
                        break;
                    } else {
                        // Handle error in case of an invalid action
                        console.log(`Error: Action "${currentAction}" is not a valid tool`);
                        this.interMessages.push({
                            role: 'user',
                            content: `<stage number="${i + 1}" role="system-error-reporter">Action "${currentAction}" is not a valid tool, try again.</stage>`,
                        });
                        break;
                    }
                } else if (key === 'action_input') {
                    // Handle action input stage
                    const actionInput = stage[key];
                    console.log(`Action input full:`, actionInput);
                    console.log(`Action input:`, actionInput.inputs);

                    if (currentAction) {
                        try {
                            // Process the action with its input
                            // eslint-disable-next-line no-await-in-loop
                            const observation = (await this.processAction(currentAction, actionInput.inputs)) as Observation[];
                            const nextPrompt = [{ type: 'text', text: `<stage number="${i + 1}" role="user"> <observation>` }, ...observation, { type: 'text', text: '</observation></stage>' }] as Observation[];
                            console.log(observation);
                            this.interMessages.push({ role: 'user', content: nextPrompt });
                            this.processingNumber++;
                            console.log(`Tool ${currentAction} executed successfully. Observations:`, observation);

                            break;
                        } catch (error) {
                            console.error(`Error during execution of tool '${currentAction}':`, error);
                            const errorMessage = error instanceof Error ? error.message : String(error);
                            // Return an error observation formatted for the LLM loop
                            return {
                                role: ASSISTANT_ROLE.USER,
                                content: [
                                    {
                                        type: TEXT_TYPE.ERROR,
                                        text: `<observation><error tool="${currentAction}">Execution failed: ${escape(errorMessage)}</error></observation>`,
                                        index: 0,
                                        citation_ids: null,
                                    },
                                ],
                                processing_info: [],
                            };
                        }
                    } else {
                        throw new Error('Error: Action input without a valid action');
                    }
                } else if (key === 'answer') {
                    // If an answer is found, end the query
                    console.log('Answer found. Ending query.');
                    this.streamedAnswerParser.reset();
                    const parsedAnswer = AnswerParser.parse(result, this.processingInfo);
                    return parsedAnswer;
                }
            }
        }

        throw new Error('Reached maximum turns. Ending query.');
    }

    private constructUserPrompt(stageNumber: number, role: string, content: string): string {
        return `<stage number="${stageNumber}" role="${role}">${content}</stage>`;
    }

    /**
     * Executes a step in the conversation, processing the assistant's response and parsing it in real-time.
     * @param onProcessingUpdate Callback for processing updates.
     * @param onAnswerUpdate Callback for answer updates.
     * @returns The full response from the assistant.
     */
    private async execute(onProcessingUpdate: (processingUpdate: ProcessingInfo[]) => void, onAnswerUpdate: (answerUpdate: string) => void): Promise<string> {
        // Stream OpenAI response for real-time updates
        const stream = await this.client.chat.completions.create({
            model: 'gpt-4o',
            messages: this.interMessages as ChatCompletionMessageParam[],
            temperature: 0,
            stream: true,
            stop: ['</stage>'],
        });

        let fullResponse: string = '';
        let currentTag: string = '';
        let currentContent: string = '';
        let isInsideTag: boolean = false;

        // Process each chunk of the streamed response
        for await (const chunk of stream) {
            const content = chunk.choices[0]?.delta?.content || '';
            fullResponse += content;

            // Parse the streamed content character by character
            for (const char of content) {
                if (currentTag === 'answer') {
                    // Handle answer parsing for real-time updates
                    currentContent += char;
                    const streamedAnswer = this.streamedAnswerParser.parse(char);
                    onAnswerUpdate(streamedAnswer);
                    continue;
                } else if (char === '<') {
                    // Start of a new tag
                    isInsideTag = true;
                    currentTag = '';
                    currentContent = '';
                } else if (char === '>') {
                    // End of the tag
                    isInsideTag = false;
                    if (currentTag.startsWith('/')) {
                        currentTag = '';
                    }
                } else if (isInsideTag) {
                    // Append characters to the tag name
                    currentTag += char;
                } else if (currentTag === 'thought' || currentTag === 'action_input_description') {
                    // Handle processing information for thought or action input description
                    currentContent += char;
                    const current_info = this.processingInfo.find(info => info.index === this.processingNumber);
                    if (current_info) {
                        current_info.content = currentContent.trim();
                        onProcessingUpdate(this.processingInfo);
                    } else {
                        this.processingInfo.push({
                            index: this.processingNumber,
                            type: currentTag === 'thought' ? PROCESSING_TYPE.THOUGHT : PROCESSING_TYPE.ACTION,
                            content: currentContent.trim(),
                        });
                        onProcessingUpdate(this.processingInfo);
                    }
                }
            }
        }

        return fullResponse;
    }

    /**
     * Validates the assistant's response to ensure it conforms to the expected XML structure.
     * @param response The parsed XML response from the assistant.
     * @throws An error if the response does not meet the expected structure.
     */
    private validateAssistantResponse(response: { stage: { [key: string]: object | string } }) {
        if (!response.stage) {
            throw new Error('Response does not contain a <stage> element');
        }

        // Validate that the stage has the required attributes
        const stage = response.stage;
        if (!stage['@_number'] || !stage['@_role']) {
            throw new Error('Stage element must have "number" and "role" attributes');
        }

        // Extract the role of the stage to determine expected content
        const role = stage['@_role'];

        // Depending on the role, validate the presence of required elements
        if (role === 'assistant') {
            // Assistant's response should contain either 'thought', 'action', 'action_input', or 'answer'
            if (!('thought' in stage || 'action' in stage || 'action_input' in stage || 'answer' in stage)) {
                throw new Error('Assistant stage must contain a thought, action, action_input, or answer element');
            }

            // If 'thought' is present, validate it
            if ('thought' in stage) {
                if (typeof stage.thought !== 'string' || stage.thought.trim() === '') {
                    throw new Error('Thought must be a non-empty string');
                }
            }

            // If 'action' is present, validate it
            if ('action' in stage) {
                if (typeof stage.action !== 'string' || stage.action.trim() === '') {
                    throw new Error('Action must be a non-empty string');
                }

                // Optional: Check if the action is among allowed actions (including dynamic tools)
                const allowedActions = [...Object.keys(this.tools), ...Array.from(this.dynamicToolRegistry.keys())];
                if (!allowedActions.includes(stage.action)) {
                    throw new Error(`Action "${stage.action}" is not a valid tool`);
                }
            }

            // If 'action_input' is present, validate its structure
            if ('action_input' in stage) {
                const actionInput = stage.action_input as object;

                if (!('action_input_description' in actionInput) || typeof actionInput.action_input_description !== 'string') {
                    throw new Error('action_input must contain an action_input_description string');
                }

                if (!('inputs' in actionInput)) {
                    throw new Error('action_input must contain an inputs object');
                }

                // Further validation of inputs can be done here based on the expected parameters of the action
            }

            // If 'answer' is present, validate its structure
            if ('answer' in stage) {
                const answer = stage.answer as object;

                // Ensure answer contains at least one of the required elements
                if (!('grounded_text' in answer || 'normal_text' in answer)) {
                    throw new Error('Answer must contain grounded_text or normal_text');
                }

                // Validate follow_up_questions
                if (!('follow_up_questions' in answer)) {
                    throw new Error('Answer must contain follow_up_questions');
                }

                // Validate loop_summary
                if (!('loop_summary' in answer)) {
                    throw new Error('Answer must contain a loop_summary');
                }

                // Additional validation for citations, grounded_text, etc., can be added here
            }
        } else if (role === 'user') {
            // User's stage should contain 'query' or 'observation'
            if (!('query' in stage || 'observation' in stage)) {
                throw new Error('User stage must contain a query or observation element');
            }

            // Validate 'query' if present
            if ('query' in stage && typeof stage.query !== 'string') {
                throw new Error('Query must be a string');
            }

            // Validate 'observation' if present
            if ('observation' in stage) {
                // Ensure observation has the correct structure
                // This can be expanded based on how observations are structured
            }
        } else {
            throw new Error(`Unknown role "${role}" in stage`);
        }

        // Add any additional validation rules as necessary
    }

    /**
     * Helper function to check if a string can be parsed as an array of the expected type.
     * @param input The input string to check.
     * @param expectedType The expected type of the array elements ('string', 'number', or 'boolean').
     * @returns The parsed array if valid, otherwise throws an error.
     */
    private parseArray<T>(input: string, expectedType: 'string' | 'number' | 'boolean'): T[] {
        try {
            // Parse the input string into a JSON object
            const parsed = JSON.parse(input);

            // Check if the parsed object is an array and if all elements are of the expected type
            if (Array.isArray(parsed) && parsed.every(item => typeof item === expectedType)) {
                return parsed;
            } else {
                throw new Error(`Invalid ${expectedType} array format.`);
            }
        } catch (error) {
            throw new Error(`Failed to parse ${expectedType} array: ` + error);
        }
    }

    /**
     * Processes a specific action by invoking the appropriate tool with the provided inputs.
     * This method ensures that the action exists and validates the types of `actionInput`
     * based on the tool's parameter rules. It throws errors for missing required parameters
     * or mismatched types before safely executing the tool with the validated input.
     *
     * NOTE: In the future, it should typecheck for specific tool parameter types using the `TypeMap` or otherwise.
     *
     * Type validation includes checks for:
     * - `string`, `number`, `boolean`
     * - `string[]`, `number[]` (arrays of strings or numbers)
     *
     * @param action The action to perform. It corresponds to a registered tool.
     * @param actionInput The inputs for the action, passed as an object where each key is a parameter name.
     * @returns A promise that resolves to an array of `Observation` objects representing the result of the action.
     * @throws An error if the action is unknown, if required parameters are missing, or if input types don't match the expected parameter types.
     */
    private async processAction(action: string, actionInput: ParametersType<ReadonlyArray<Parameter>>): Promise<Observation[]> {
        // Check if the action exists in the tools list or dynamic registry
        if (!(action in this.tools) && !this.dynamicToolRegistry.has(action)) {
            throw new Error(`Unknown action: ${action}`);
        }
        console.log(actionInput);

        // Determine which tool to use - either from static tools or dynamic registry
        const tool = this.tools[action] || this.dynamicToolRegistry.get(action);

        // Special handling for documentMetadata tool with numeric or boolean fieldValue
        if (action === 'documentMetadata') {
            // Handle single field edit
            if ('fieldValue' in actionInput) {
                if (typeof actionInput.fieldValue === 'number' || typeof actionInput.fieldValue === 'boolean') {
                    // Convert number or boolean to string to pass validation
                    actionInput.fieldValue = String(actionInput.fieldValue);
                }
            }

            // Handle fieldEdits parameter (for multiple field edits)
            if ('fieldEdits' in actionInput && actionInput.fieldEdits) {
                try {
                    // If it's already an array, stringify it to ensure it passes validation
                    if (Array.isArray(actionInput.fieldEdits)) {
                        actionInput.fieldEdits = JSON.stringify(actionInput.fieldEdits);
                    }
                    // If it's an object but not an array, it might be a single edit - convert to array and stringify
                    else if (typeof actionInput.fieldEdits === 'object') {
                        actionInput.fieldEdits = JSON.stringify([actionInput.fieldEdits]);
                    }
                    // Otherwise, ensure it's a string for the validator
                    else if (typeof actionInput.fieldEdits !== 'string') {
                        actionInput.fieldEdits = String(actionInput.fieldEdits);
                    }
                } catch (error) {
                    console.error('Error processing fieldEdits:', error);
                    // Don't fail validation here, let the tool handle it
                }
            }
        }

        // Special handling for createNewTool with parsed XML toolCode
        if (action === 'createNewTool') {
            if ('toolCode' in actionInput && typeof actionInput.toolCode === 'object' && actionInput.toolCode !== null) {
                try {
                    // Convert the parsed XML object back to a string
                    const extractText = (obj: any): string => {
                        if (typeof obj === 'string') {
                            return obj;
                        } else if (obj && typeof obj === 'object') {
                            if (obj._text) {
                                return obj._text;
                            }
                            // Recursively extract text from all properties
                            let text = '';
                            for (const key in obj) {
                                if (key !== '_text') {
                                    const value = obj[key];
                                    if (typeof value === 'string') {
                                        text += value + '\n';
                                    } else if (value && typeof value === 'object') {
                                        text += extractText(value) + '\n';
                                    }
                                }
                            }
                            return text;
                        }
                        return '';
                    };

                    const reconstructedCode = extractText(actionInput.toolCode);
                    actionInput.toolCode = reconstructedCode;
                } catch (error) {
                    console.error('Error processing toolCode:', error);
                    // Convert to string as fallback
                    actionInput.toolCode = String(actionInput.toolCode);
                }
            }
        }

        // Check parameter requirements and types for the tool
        for (const param of tool.parameterRules) {
            // Check if the parameter is required and missing in the input
            if (param.required && !(param.name in actionInput) && !tool.inputValidator(actionInput)) {
                throw new Error(`Missing required parameter: ${param.name}`);
            }

            // Check if the parameter type matches the expected type
            const expectedType = param.type.replace('[]', '') as 'string' | 'number' | 'boolean';
            const isArray = param.type.endsWith('[]');
            const input = actionInput[param.name];

            if (isArray) {
                // Check if the input is a valid array of the expected type
                const parsedArray = this.parseArray(input as string, expectedType);
                actionInput[param.name] = parsedArray as TypeMap[typeof param.type];
            } else if (input !== undefined && typeof input !== expectedType) {
                throw new Error(`Invalid type for parameter ${param.name}: expected ${expectedType}`);
            }
        }

        // Execute the tool with the validated inputs
        return await tool.execute(actionInput);
    }

    /**
     * Gets a combined list of all tools, both static and dynamic
     * @returns An array of all available tool instances
     */
    private getAllTools(): BaseTool<ReadonlyArray<Parameter>>[] {
        // Combine static and dynamic tools
        return [...Object.values(this.tools), ...Array.from(this.dynamicToolRegistry.values())];
    }

    /**
     * Overridden method to get the React prompt with all tools (static + dynamic)
     */
    private getSystemPromptWithAllTools(): string {
        const allTools = this.getAllTools();
        const docSummaries = () => JSON.stringify(this._docManager.listDocs);
        const chatHistory = this._history();

        return getReactPrompt(allTools, docSummaries, chatHistory);
    }

    /**
     * Reinitializes the DocumentMetadataTool with a direct reference to the ChatBox instance.
     * This ensures that the tool can properly access the ChatBox document and find related documents.
     *
     * @param chatBox The ChatBox instance to pass to the DocumentMetadataTool
     */
    public reinitializeDocumentMetadataTool(): void {
        if (this.tools && this.tools.documentMetadata) {
            this.tools.documentMetadata = new DocumentMetadataTool(this._docManager);
            console.log('Agent: Reinitialized DocumentMetadataTool with ChatBox instance');
        } else {
            console.warn('Agent: Could not reinitialize DocumentMetadataTool - tool not found');
        }
    }
}

// Forward declaration to avoid circular import
interface AgentLike {
    registerDynamicTool(toolName: string, toolInstance: BaseTool<ReadonlyArray<Parameter>>): void;
    notifyToolCreated(toolName: string, completeToolCode: string): void;
}