aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/nodes/chatbot/agentsystem/Agent.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/views/nodes/chatbot/agentsystem/Agent.ts')
-rw-r--r--src/client/views/nodes/chatbot/agentsystem/Agent.ts486
1 files changed, 486 insertions, 0 deletions
diff --git a/src/client/views/nodes/chatbot/agentsystem/Agent.ts b/src/client/views/nodes/chatbot/agentsystem/Agent.ts
new file mode 100644
index 000000000..e93fb87db
--- /dev/null
+++ b/src/client/views/nodes/chatbot/agentsystem/Agent.ts
@@ -0,0 +1,486 @@
+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 { CreateDocTool } from '../tools/CreateDocumentTool';
+import { DataAnalysisTool } from '../tools/DataAnalysisTool';
+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 { Doc } from '../../../../../fields/Doc';
+import { parsedDoc } from '../chatboxcomponents/ChatBox';
+import { WebsiteInfoScraperTool } from '../tools/WebsiteInfoScraperTool';
+import { Upload } from '../../../../../server/SharedMediaTypes';
+import { RAGTool } from '../tools/RAGTool';
+//import { CreateTextDocTool } from '../tools/CreateTextDocumentTool';
+
+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 _summaries: () => 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>>>;
+
+ /**
+ * 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 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 createCSVInDash A function to create a CSV document in the dashboard.
+ */
+ 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,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ createCSVInDash: (url: string, title: string, id: string, data: string) => void
+ ) {
+ // 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;
+
+ // 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),
+ // createCSV: new CreateCSVTool(createCSVInDash),
+ noTool: new NoTool(),
+ imageCreationTool: new ImageCreationTool(createImage),
+ // createTextDoc: new CreateTextDocTool(addLinkedDoc),
+ createDoc: new CreateDocTool(addLinkedDoc),
+ // createAnyDocument: new CreateAnyDocumentTool(addLinkedDoc),
+ // dictionary: new DictionaryTool(),
+ };
+ }
+
+ /**
+ * 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 = 30): 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 sanitizedQuestion = escape(question); // Sanitized user input
+
+ // 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();
+ const systemPrompt = getReactPrompt(Object.values(this.tools), this._summaries, chatHistory);
+
+ // 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}`);
+
+ if (this.tools[currentAction]) {
+ // 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>`,
+ } 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');
+ this.interMessages.push({
+ role: 'user',
+ content: `<stage number="${i + 1}" role="system-error-reporter">No valid action, 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++;
+ break;
+ } catch (error) {
+ throw new Error(`Error processing action: ${error}`);
+ }
+ } 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
+ const allowedActions = Object.keys(this.tools);
+ 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
+ if (!(action in this.tools)) {
+ throw new Error(`Unknown action: ${action}`);
+ }
+ console.log(actionInput);
+
+ for (const param of this.tools[action].parameterRules) {
+ // Check if the parameter is required and missing in the input
+ if (param.required && !(param.name in actionInput) && !this.tools[action].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}`);
+ }
+ }
+
+ const tool = this.tools[action];
+
+ return await tool.execute(actionInput);
+ }
+}