You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

440 lines
14 KiB

11 months ago
  1. const AIbitat = require("./aibitat");
  2. const AgentPlugins = require("./aibitat/plugins");
  3. const ImportedPlugin = require("./imported");
  4. const { httpSocket } = require("./aibitat/plugins/http-socket.js");
  5. const { WorkspaceChats } = require("../../models/workspaceChats");
  6. const { safeJsonParse } = require("../http");
  7. const {
  8. USER_AGENT,
  9. WORKSPACE_AGENT,
  10. agentSkillsFromSystemSettings,
  11. } = require("./defaults");
  12. const { AgentHandler } = require(".");
  13. const {
  14. WorkspaceAgentInvocation,
  15. } = require("../../models/workspaceAgentInvocation");
  16. /**
  17. * This is an instance and functional Agent handler, but it does not utilize
  18. * sessions or websocket's and is instead a singular one-off agent run that does
  19. * not persist between invocations
  20. */
  21. class EphemeralAgentHandler extends AgentHandler {
  22. /** @type {string|null} the unique identifier for the agent invocation */
  23. #invocationUUID = null;
  24. /** @type {import("@prisma/client").workspaces|null} the workspace to use for the agent */
  25. #workspace = null;
  26. /** @type {import("@prisma/client").users|null} the user id to use for the agent */
  27. #userId = null;
  28. /** @type {import("@prisma/client").workspace_threads|null} the workspace thread id to use for the agent */
  29. #threadId = null;
  30. /** @type {string|null} the session id to use for the agent */
  31. #sessionId = null;
  32. /** @type {string|null} the prompt to use for the agent */
  33. #prompt = null;
  34. /** @type {string[]} the functions to load into the agent (Aibitat plugins) */
  35. #funcsToLoad = [];
  36. /** @type {AIbitat|null} */
  37. aibitat = null;
  38. /** @type {string|null} */
  39. channel = null;
  40. /** @type {string|null} */
  41. provider = null;
  42. /** @type {string|null} the model to use for the agent */
  43. model = null;
  44. /**
  45. * @param {{
  46. * uuid: string,
  47. * workspace: import("@prisma/client").workspaces,
  48. * prompt: string,
  49. * userId: import("@prisma/client").users["id"]|null,
  50. * threadId: import("@prisma/client").workspace_threads["id"]|null,
  51. * sessionId: string|null
  52. * }} parameters
  53. */
  54. constructor({
  55. uuid,
  56. workspace,
  57. prompt,
  58. userId = null,
  59. threadId = null,
  60. sessionId = null,
  61. }) {
  62. super({ uuid });
  63. this.#invocationUUID = uuid;
  64. this.#workspace = workspace;
  65. this.#prompt = prompt;
  66. this.#userId = userId;
  67. this.#threadId = threadId;
  68. this.#sessionId = sessionId;
  69. }
  70. log(text, ...args) {
  71. console.log(`\x1b[36m[EphemeralAgentHandler]\x1b[0m ${text}`, ...args);
  72. }
  73. closeAlert() {
  74. this.log(`End ${this.#invocationUUID}::${this.provider}:${this.model}`);
  75. }
  76. async #chatHistory(limit = 10) {
  77. try {
  78. const rawHistory = (
  79. await WorkspaceChats.where(
  80. {
  81. workspaceId: this.#workspace.id,
  82. user_id: this.#userId || null,
  83. thread_id: this.#threadId || null,
  84. api_session_id: this.#sessionId,
  85. include: true,
  86. },
  87. limit,
  88. { id: "desc" }
  89. )
  90. ).reverse();
  91. const agentHistory = [];
  92. rawHistory.forEach((chatLog) => {
  93. agentHistory.push(
  94. {
  95. from: USER_AGENT.name,
  96. to: WORKSPACE_AGENT.name,
  97. content: chatLog.prompt,
  98. state: "success",
  99. },
  100. {
  101. from: WORKSPACE_AGENT.name,
  102. to: USER_AGENT.name,
  103. content: safeJsonParse(chatLog.response)?.text || "",
  104. state: "success",
  105. }
  106. );
  107. });
  108. return agentHistory;
  109. } catch (e) {
  110. this.log("Error loading chat history", e.message);
  111. return [];
  112. }
  113. }
  114. /**
  115. * Attempts to find a fallback provider and model to use if the workspace
  116. * does not have an explicit `agentProvider` and `agentModel` set.
  117. * 1. Fallback to the workspace `chatProvider` and `chatModel` if they exist.
  118. * 2. Fallback to the system `LLM_PROVIDER` and try to load the the associated default model via ENV params or a base available model.
  119. * 3. Otherwise, return null - will likely throw an error the user can act on.
  120. * @returns {object|null} - An object with provider and model keys.
  121. */
  122. #getFallbackProvider() {
  123. // First, fallback to the workspace chat provider and model if they exist
  124. if (this.#workspace.chatProvider && this.#workspace.chatModel) {
  125. return {
  126. provider: this.#workspace.chatProvider,
  127. model: this.#workspace.chatModel,
  128. };
  129. }
  130. // If workspace does not have chat provider and model fallback
  131. // to system provider and try to load provider default model
  132. const systemProvider = process.env.LLM_PROVIDER;
  133. const systemModel = this.providerDefault(systemProvider);
  134. if (systemProvider && systemModel) {
  135. return {
  136. provider: systemProvider,
  137. model: systemModel,
  138. };
  139. }
  140. return null;
  141. }
  142. /**
  143. * Finds or assumes the model preference value to use for API calls.
  144. * If multi-model loading is supported, we use their agent model selection of the workspace
  145. * If not supported, we attempt to fallback to the system provider value for the LLM preference
  146. * and if that fails - we assume a reasonable base model to exist.
  147. * @returns {string|null} the model preference value to use in API calls
  148. */
  149. #fetchModel() {
  150. // Provider was not explicitly set for workspace, so we are going to run our fallback logic
  151. // that will set a provider and model for us to use.
  152. if (!this.provider) {
  153. const fallback = this.#getFallbackProvider();
  154. if (!fallback) throw new Error("No valid provider found for the agent.");
  155. this.provider = fallback.provider; // re-set the provider to the fallback provider so it is not null.
  156. return fallback.model; // set its defined model based on fallback logic.
  157. }
  158. // The provider was explicitly set, so check if the workspace has an agent model set.
  159. if (this.#workspace.agentModel) return this.#workspace.agentModel;
  160. // Otherwise, we have no model to use - so guess a default model to use via the provider
  161. // and it's system ENV params and if that fails - we return either a base model or null.
  162. return this.providerDefault();
  163. }
  164. #providerSetupAndCheck() {
  165. this.provider = this.#workspace.agentProvider ?? null;
  166. this.model = this.#fetchModel();
  167. if (!this.provider)
  168. throw new Error("No valid provider found for the agent.");
  169. this.log(`Start ${this.#invocationUUID}::${this.provider}:${this.model}`);
  170. this.checkSetup();
  171. }
  172. #attachPlugins(args) {
  173. for (const name of this.#funcsToLoad) {
  174. // Load child plugin
  175. if (name.includes("#")) {
  176. const [parent, childPluginName] = name.split("#");
  177. if (!AgentPlugins.hasOwnProperty(parent)) {
  178. this.log(
  179. `${parent} is not a valid plugin. Skipping inclusion to agent cluster.`
  180. );
  181. continue;
  182. }
  183. const childPlugin = AgentPlugins[parent].plugin.find(
  184. (child) => child.name === childPluginName
  185. );
  186. if (!childPlugin) {
  187. this.log(
  188. `${parent} does not have child plugin named ${childPluginName}. Skipping inclusion to agent cluster.`
  189. );
  190. continue;
  191. }
  192. const callOpts = this.parseCallOptions(
  193. args,
  194. childPlugin?.startupConfig?.params,
  195. name
  196. );
  197. this.aibitat.use(childPlugin.plugin(callOpts));
  198. this.log(
  199. `Attached ${parent}:${childPluginName} plugin to Agent cluster`
  200. );
  201. continue;
  202. }
  203. // Load imported plugin. This is marked by `@@` in the array of functions to load.
  204. // and is the @@hubID of the plugin.
  205. if (name.startsWith("@@")) {
  206. const hubId = name.replace("@@", "");
  207. const valid = ImportedPlugin.validateImportedPluginHandler(hubId);
  208. if (!valid) {
  209. this.log(
  210. `Imported plugin by hubId ${hubId} not found in plugin directory. Skipping inclusion to agent cluster.`
  211. );
  212. continue;
  213. }
  214. const plugin = ImportedPlugin.loadPluginByHubId(hubId);
  215. const callOpts = plugin.parseCallOptions();
  216. this.aibitat.use(plugin.plugin(callOpts));
  217. this.log(
  218. `Attached ${plugin.name} (${hubId}) imported plugin to Agent cluster`
  219. );
  220. continue;
  221. }
  222. // Load single-stage plugin.
  223. if (!AgentPlugins.hasOwnProperty(name)) {
  224. this.log(
  225. `${name} is not a valid plugin. Skipping inclusion to agent cluster.`
  226. );
  227. continue;
  228. }
  229. const callOpts = this.parseCallOptions(
  230. args,
  231. AgentPlugins[name].startupConfig.params
  232. );
  233. const AIbitatPlugin = AgentPlugins[name];
  234. this.aibitat.use(AIbitatPlugin.plugin(callOpts));
  235. this.log(`Attached ${name} plugin to Agent cluster`);
  236. }
  237. }
  238. async #loadAgents() {
  239. // Default User agent and workspace agent
  240. this.log(`Attaching user and default agent to Agent cluster.`);
  241. this.aibitat.agent(USER_AGENT.name, await USER_AGENT.getDefinition());
  242. this.aibitat.agent(
  243. WORKSPACE_AGENT.name,
  244. await WORKSPACE_AGENT.getDefinition(this.provider)
  245. );
  246. this.#funcsToLoad = [
  247. AgentPlugins.memory.name,
  248. AgentPlugins.docSummarizer.name,
  249. AgentPlugins.webScraping.name,
  250. ...(await agentSkillsFromSystemSettings()),
  251. ...(await ImportedPlugin.activeImportedPlugins()),
  252. ];
  253. }
  254. async init() {
  255. this.#providerSetupAndCheck();
  256. return this;
  257. }
  258. async createAIbitat(
  259. args = {
  260. handler,
  261. }
  262. ) {
  263. this.aibitat = new AIbitat({
  264. provider: this.provider ?? "openai",
  265. model: this.model ?? "gpt-4o",
  266. chats: await this.#chatHistory(20),
  267. handlerProps: {
  268. invocation: {
  269. workspace: this.#workspace,
  270. workspace_id: this.#workspace.id,
  271. },
  272. log: this.log,
  273. },
  274. });
  275. // Attach HTTP response object if defined for chunk streaming.
  276. this.log(`Attached ${httpSocket.name} plugin to Agent cluster`);
  277. this.aibitat.use(
  278. httpSocket.plugin({
  279. handler: args.handler,
  280. muteUserReply: true,
  281. introspection: true,
  282. })
  283. );
  284. // Load required agents (Default + custom)
  285. await this.#loadAgents();
  286. // Attach all required plugins for functions to operate.
  287. this.#attachPlugins(args);
  288. }
  289. startAgentCluster() {
  290. return this.aibitat.start({
  291. from: USER_AGENT.name,
  292. to: this.channel ?? WORKSPACE_AGENT.name,
  293. content: this.#prompt,
  294. });
  295. }
  296. /**
  297. * Determine if the message provided is an agent invocation.
  298. * @param {{message:string}} parameters
  299. * @returns {boolean}
  300. */
  301. static isAgentInvocation({ message }) {
  302. const agentHandles = WorkspaceAgentInvocation.parseAgents(message);
  303. if (agentHandles.length > 0) return true;
  304. return false;
  305. }
  306. }
  307. const EventEmitter = require("node:events");
  308. const { writeResponseChunk } = require("../helpers/chat/responses");
  309. /**
  310. * This is a special EventEmitter specifically used in the Aibitat agent handler
  311. * that enables us to use HTTP to relay all .introspect and .send events back to an
  312. * http handler instead of websockets, like we do on the frontend. This interface is meant to
  313. * mock a websocket interface for the methods used and bind them to an HTTP method so that the developer
  314. * API can invoke agent calls.
  315. */
  316. class EphemeralEventListener extends EventEmitter {
  317. messages = [];
  318. constructor() {
  319. super();
  320. }
  321. send(jsonData) {
  322. const data = JSON.parse(jsonData);
  323. this.messages.push(data);
  324. this.emit("chunk", data);
  325. }
  326. close() {
  327. this.emit("closed");
  328. }
  329. /**
  330. * Compacts all messages in class and returns them in a condensed format.
  331. * @returns {{thoughts: string[], textResponse: string}}
  332. */
  333. packMessages() {
  334. const thoughts = [];
  335. let textResponse = null;
  336. for (let msg of this.messages) {
  337. if (msg.type !== "statusResponse") {
  338. textResponse = msg.content;
  339. } else {
  340. thoughts.push(msg.content);
  341. }
  342. }
  343. return { thoughts, textResponse };
  344. }
  345. /**
  346. * Waits on the HTTP plugin to emit the 'closed' event from the agentHandler
  347. * so that we can compact and return all the messages in the current queue.
  348. * @returns {Promise<{thoughts: string[], textResponse: string}>}
  349. */
  350. async waitForClose() {
  351. return new Promise((resolve) => {
  352. this.once("closed", () => resolve(this.packMessages()));
  353. });
  354. }
  355. /**
  356. * Streams the events with `writeResponseChunk` over HTTP chunked encoding
  357. * and returns on the close event emission.
  358. * ----------
  359. * DevNote: Agents do not stream so in here we are simply
  360. * emitting the thoughts and text response as soon as we get them.
  361. * @param {import("express").Response} response
  362. * @param {string} uuid - Unique identifier that is the same across chunks.
  363. * @returns {Promise<{thoughts: string[], textResponse: string}>}
  364. */
  365. async streamAgentEvents(response, uuid) {
  366. const onChunkHandler = (data) => {
  367. if (data.type === "statusResponse") {
  368. return writeResponseChunk(response, {
  369. id: uuid,
  370. type: "agentThought",
  371. thought: data.content,
  372. sources: [],
  373. attachments: [],
  374. close: false,
  375. error: null,
  376. animate: true,
  377. });
  378. }
  379. return writeResponseChunk(response, {
  380. id: uuid,
  381. type: "textResponse",
  382. textResponse: data.content,
  383. sources: [],
  384. attachments: [],
  385. close: true,
  386. error: null,
  387. animate: false,
  388. });
  389. };
  390. this.on("chunk", onChunkHandler);
  391. // Wait for close and after remove chunk listener
  392. return this.waitForClose().then((closedResponse) => {
  393. this.removeListener("chunk", onChunkHandler);
  394. return closedResponse;
  395. });
  396. }
  397. }
  398. module.exports = { EphemeralAgentHandler, EphemeralEventListener };