diff options
Diffstat (limited to 'src/client/views/nodes/chatbot/agentsystem')
| -rw-r--r-- | src/client/views/nodes/chatbot/agentsystem/Agent.ts | 382 | ||||
| -rw-r--r-- | src/client/views/nodes/chatbot/agentsystem/prompts.ts | 8 |
2 files changed, 340 insertions, 50 deletions
diff --git a/src/client/views/nodes/chatbot/agentsystem/Agent.ts b/src/client/views/nodes/chatbot/agentsystem/Agent.ts index 8075cab5f..361c5eb2b 100644 --- a/src/client/views/nodes/chatbot/agentsystem/Agent.ts +++ b/src/client/views/nodes/chatbot/agentsystem/Agent.ts @@ -7,8 +7,8 @@ import { AnswerParser } from '../response_parsers/AnswerParser'; import { StreamedAnswerParser } from '../response_parsers/StreamedAnswerParser'; import { BaseTool } from '../tools/BaseTool'; import { CalculateTool } from '../tools/CalculateTool'; -import { CreateDocTool } from '../tools/CreateDocumentTool'; import { DataAnalysisTool } from '../tools/DataAnalysisTool'; +import { DocumentMetadataTool } from '../tools/DocumentMetadataTool'; import { NoTool } from '../tools/NoTool'; import { SearchTool } from '../tools/SearchTool'; import { Parameter, ParametersType, TypeMap } from '../types/tool_types'; @@ -17,11 +17,17 @@ import { Vectorstore } from '../vectorstore/Vectorstore'; import { getReactPrompt } from './prompts'; import { ChatCompletionMessageParam } from 'openai/resources'; import { Doc } from '../../../../../fields/Doc'; -import { parsedDoc } from '../chatboxcomponents/ChatBox'; +import { ChatBox, parsedDoc } from '../chatboxcomponents/ChatBox'; import { WebsiteInfoScraperTool } from '../tools/WebsiteInfoScraperTool'; import { Upload } from '../../../../../server/SharedMediaTypes'; import { RAGTool } from '../tools/RAGTool'; -import { GPTTutorialTool } from '../tools/TutorialTool'; +import { AgentDocumentManager } from '../utils/AgentDocumentManager'; +import { CreateLinksTool } from '../tools/CreateLinksTool'; +import { CodebaseSummarySearchTool } from '../tools/CodebaseSummarySearchTool'; +import { FileContentTool } from '../tools/FileContentTool'; +import { FileNamesTool } from '../tools/FileNamesTool'; +import { CreateNewTool } from '../tools/CreateNewTool'; +//import { CreateTextDocTool } from '../tools/CreateTextDocumentTool'; dotenv.config(); @@ -36,7 +42,6 @@ export class Agent { private interMessages: AgentMessage[] = []; private vectorstore: Vectorstore; private _history: () => string; - private _summaries: () => string; private _csvData: () => { filename: string; id: string; text: string }[]; private actionNumber: number = 0; private thoughtNumber: number = 0; @@ -44,47 +49,203 @@ export class Agent { private processingInfo: ProcessingInfo[] = []; private streamedAnswerParser: StreamedAnswerParser = new StreamedAnswerParser(); private tools: Record<string, BaseTool<ReadonlyArray<Parameter>>>; - private Document: Doc; + private _docManager: AgentDocumentManager; + // 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. + * @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 addLinkedUrlDoc A function to add a linked document from a URL. + * @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, - summaries: () => string, history: () => string, csvData: () => { filename: string; id: string; text: string }[], - addLinkedUrlDoc: (url: string, id: string) => void, createImage: (result: Upload.FileInformation & Upload.InspectionResults, options: DocumentOptions) => void, - addLinkedDoc: (doc: parsedDoc) => Doc | undefined, createCSVInDash: (url: string, title: string, id: string, data: string) => void, - document: Doc + 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._history = history; - this._summaries = summaries; this._csvData = csvData; - this.Document = document; + this._docManager = docManager; + + // 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(addLinkedUrlDoc), - searchTool: new SearchTool(addLinkedUrlDoc), + websiteInfoScraper: new WebsiteInfoScraperTool(this._docManager), + searchTool: new SearchTool(this._docManager), noTool: new NoTool(), - createDoc: new CreateDocTool(addLinkedDoc), - generateTutorialNode: new GPTTutorialTool(addLinkedDoc), + //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), }; + + // 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 ?? []; } /** @@ -96,13 +257,20 @@ export class Agent { * @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 = 30): Promise<AssistantMessage> { + 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) { - return { role: ASSISTANT_ROLE.ASSISTANT, content: [{ text: 'User query too long. Please shorten your question and try again.', index: 0, type: TEXT_TYPE.NORMAL, citation_ids: null }], processing_info: [] }; + 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 @@ -110,20 +278,8 @@ export class Agent { // Push sanitized user's question to message history this.messages.push({ role: 'user', content: sanitizedQuestion }); - // Retrieve chat history and generate system prompt - const chatHistory = this._history(); - let systemPrompt = getReactPrompt(Object.values(this.tools), this._summaries, chatHistory); - - // If this is a Dash documentation assistant chat, modify the system prompt - if (this.Document?.is_dash_doc_assistant) { - systemPrompt = systemPrompt.replace( - '<task>', - `<task> - IMPORTANT: You are specifically focused on helping users with questions about Dash documentation and usage. When users ask questions, interpret them in the context of Dash documentation and features, even if they don't explicitly mention Dash. For example, if a user asks "How do I create a document?", interpret this as "How do I create a document in Dash?" and provide relevant Dash-specific guidance. - - For any questions about Dash features, functionality, or usage, you should use the generateTutorialNode tool to create a tutorial document that explains the concept in detail. This tool will help create well-formatted, interactive tutorials that guide users through Dash features.` - ); - } + // Get system prompt with all tools (static + dynamic) + const systemPrompt = this.getSystemPromptWithAllTools(); // Initialize intermediate messages this.interMessages = [{ role: 'system', content: systemPrompt }]; @@ -186,22 +342,25 @@ export class Agent { currentAction = stage[key] as string; console.log(`Action: ${currentAction}`); - if (this.tools[currentAction]) { + // Check both static tools and dynamic registry + const tool = this.tools[currentAction] || this.dynamicToolRegistry.get(currentAction); + + 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: this.tools[currentAction].getActionRule() }) + `</stage>`, + 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: No valid 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">No valid action, try again.</stage>`, + content: `<stage number="${i + 1}" role="system-error-reporter">Action "${currentAction}" is not a valid tool, try again.</stage>`, }); break; } @@ -220,9 +379,25 @@ export class Agent { console.log(observation); this.interMessages.push({ role: 'user', content: nextPrompt }); this.processingNumber++; + console.log(`Tool ${currentAction} executed successfully. Observations:`, observation); + break; } catch (error) { - throw new Error(`Error processing action: ${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'); @@ -353,8 +528,8 @@ export class Agent { throw new Error('Action must be a non-empty string'); } - // Optional: Check if the action is among allowed actions - const allowedActions = Object.keys(this.tools); + // 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`); } @@ -459,15 +634,90 @@ export class Agent { * @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 - if (!(action in this.tools)) { + // 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); - for (const param of this.tools[action].parameterRules) { + // 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) && !this.tools[action].inputValidator(actionInput)) { + if (param.required && !(param.name in actionInput) && !tool.inputValidator(actionInput)) { throw new Error(`Missing required parameter: ${param.name}`); } @@ -485,8 +735,48 @@ export class Agent { } } - const tool = this.tools[action]; - + // 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; } diff --git a/src/client/views/nodes/chatbot/agentsystem/prompts.ts b/src/client/views/nodes/chatbot/agentsystem/prompts.ts index dda6d44ef..fcb4ab450 100644 --- a/src/client/views/nodes/chatbot/agentsystem/prompts.ts +++ b/src/client/views/nodes/chatbot/agentsystem/prompts.ts @@ -103,9 +103,9 @@ export function getReactPrompt(tools: BaseTool<ReadonlyArray<Parameter>>[], summ <note>If no external tool is required, use 'no_tool', but if there might be relevant external information, use the appropriate tool.</note> </tools> - <summaries> + <available_documents> ${summaries()} - </summaries> + </available_documents> <chat_history> ${chatHistory} @@ -210,7 +210,7 @@ export function getReactPrompt(tools: BaseTool<ReadonlyArray<Parameter>>[], summ <answer> <grounded_text citation_index="1">**The 2022 World Cup** saw Argentina crowned champions, with **Lionel Messi** leading his team to victory, marking a historic moment in sports.</grounded_text> <grounded_text citation_index="2">**Qatar** experienced a **40% increase in tourism** during the World Cup, welcoming over **1.5 million visitors**, significantly boosting its economy.</grounded_text> - <normal_text>Moments like **Messi’s triumph** often become ingrained in the legacy of World Cups, immortalizing these tournaments in both sports and cultural memory. The **long-term implications** of the World Cup on Qatar's **economy, tourism**, and **global image** remain important areas of interest as the country continues to build on the momentum generated by hosting this prestigious event.</normal_text> + <normal_text>Moments like **Messi's triumph** often become ingrained in the legacy of World Cups, immortalizing these tournaments in both sports and cultural memory. The **long-term implications** of the World Cup on Qatar's **economy, tourism**, and **global image** remain important areas of interest as the country continues to build on the momentum generated by hosting this prestigious event.</normal_text> <citations> <citation index="1" chunk_id="1234" type="text">Key moments from the 2022 World Cup.</citation> <citation index="2" chunk_id="5678" type="url"></citation> @@ -218,7 +218,7 @@ export function getReactPrompt(tools: BaseTool<ReadonlyArray<Parameter>>[], summ <follow_up_questions> <question>What long-term effects has the World Cup had on Qatar's economy and infrastructure?</question> <question>Can you compare Qatar's tourism numbers with previous World Cup hosts?</question> - <question>How has Qatar’s image on the global stage evolved post-World Cup?</question> + <question>How has Qatar's image on the global stage evolved post-World Cup?</question> </follow_up_questions> <loop_summary> The assistant first used the RAG tool to extract key moments from the user documents about the 2022 World Cup. Then, the assistant utilized the website scraping tool to gather data on Qatar's tourism impact. Both tools provided valuable information, and no additional tools were needed. |
