+ {selectedSkill.imported ? (
+
+ ) : (
+
)}
- setHasChanges={setHasChanges}
- {...(configurableSkills[selectedSkill] ||
- defaultSkills[selectedSkill])}
- />
+ >
) : (
diff --git a/server/.gitignore b/server/.gitignore
index adcf7aa4b5b..e78e20b97ea 100644
--- a/server/.gitignore
+++ b/server/.gitignore
@@ -8,6 +8,7 @@ storage/tmp/*
storage/vector-cache/*.json
storage/exports
storage/imports
+storage/plugins/agent-skills/*
!storage/documents/DOCUMENTS.md
logs/server.log
*.db
diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js
index 457d7567b95..994c8e41654 100644
--- a/server/endpoints/admin.js
+++ b/server/endpoints/admin.js
@@ -24,6 +24,7 @@ const {
ROLES,
} = require("../utils/middleware/multiUserProtected");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const ImportedPlugin = require("../utils/agents/imported");
function adminEndpoints(app) {
if (!app) return;
@@ -311,7 +312,109 @@ function adminEndpoints(app) {
}
);
- // TODO: Allow specification of which props to get instead of returning all of them all the time.
+ // System preferences but only by array of labels
+ app.get(
+ "/admin/system-preferences-for",
+ [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ async (request, response) => {
+ try {
+ const requestedSettings = {};
+ const labels = request.query.labels?.split(",") || [];
+ const needEmbedder = [
+ "text_splitter_chunk_size",
+ "max_embed_chunk_size",
+ ];
+ const noRecord = [
+ "max_embed_chunk_size",
+ "agent_sql_connections",
+ "imported_agent_skills",
+ "feature_flags",
+ "meta_page_title",
+ "meta_page_favicon",
+ ];
+
+ for (const label of labels) {
+ // Skip any settings that are not explicitly defined as public
+ if (!SystemSettings.publicFields.includes(label)) continue;
+
+ // Only get the embedder if the setting actually needs it
+ let embedder = needEmbedder.includes(label)
+ ? getEmbeddingEngineSelection()
+ : null;
+ // Only get the record from db if the setting actually needs it
+ let setting = noRecord.includes(label)
+ ? null
+ : await SystemSettings.get({ label });
+
+ switch (label) {
+ case "limit_user_messages":
+ requestedSettings[label] = setting?.value === "true";
+ break;
+ case "message_limit":
+ requestedSettings[label] = setting?.value
+ ? Number(setting.value)
+ : 10;
+ break;
+ case "footer_data":
+ requestedSettings[label] = setting?.value ?? JSON.stringify([]);
+ break;
+ case "support_email":
+ requestedSettings[label] = setting?.value || null;
+ break;
+ case "text_splitter_chunk_size":
+ requestedSettings[label] =
+ setting?.value || embedder?.embeddingMaxChunkLength || null;
+ break;
+ case "text_splitter_chunk_overlap":
+ requestedSettings[label] = setting?.value || null;
+ break;
+ case "max_embed_chunk_size":
+ requestedSettings[label] =
+ embedder?.embeddingMaxChunkLength || 1000;
+ break;
+ case "agent_search_provider":
+ requestedSettings[label] = setting?.value || null;
+ break;
+ case "agent_sql_connections":
+ requestedSettings[label] =
+ await SystemSettings.brief.agent_sql_connections();
+ break;
+ case "default_agent_skills":
+ requestedSettings[label] = safeJsonParse(setting?.value, []);
+ break;
+ case "imported_agent_skills":
+ requestedSettings[label] = ImportedPlugin.listImportedPlugins();
+ break;
+ case "custom_app_name":
+ requestedSettings[label] = setting?.value || null;
+ break;
+ case "feature_flags":
+ requestedSettings[label] =
+ (await SystemSettings.getFeatureFlags()) || {};
+ break;
+ case "meta_page_title":
+ requestedSettings[label] =
+ await SystemSettings.getValueOrFallback({ label }, null);
+ break;
+ case "meta_page_favicon":
+ requestedSettings[label] =
+ await SystemSettings.getValueOrFallback({ label }, null);
+ break;
+ default:
+ break;
+ }
+ }
+
+ response.status(200).json({ settings: requestedSettings });
+ } catch (e) {
+ console.error(e);
+ response.sendStatus(500).end();
+ }
+ }
+ );
+
+ // TODO: Delete this endpoint
+ // DEPRECATED - use /admin/system-preferences-for instead with ?labels=... comma separated string of labels
app.get(
"/admin/system-preferences",
[validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
@@ -352,6 +455,7 @@ function adminEndpoints(app) {
?.value,
[]
) || [],
+ imported_agent_skills: ImportedPlugin.listImportedPlugins(),
custom_app_name:
(await SystemSettings.get({ label: "custom_app_name" }))?.value ||
null,
diff --git a/server/endpoints/experimental/imported-agent-plugins.js b/server/endpoints/experimental/imported-agent-plugins.js
new file mode 100644
index 00000000000..cdc0148cb2f
--- /dev/null
+++ b/server/endpoints/experimental/imported-agent-plugins.js
@@ -0,0 +1,50 @@
+const ImportedPlugin = require("../../utils/agents/imported");
+const { reqBody } = require("../../utils/http");
+const {
+ flexUserRoleValid,
+ ROLES,
+} = require("../../utils/middleware/multiUserProtected");
+const { validatedRequest } = require("../../utils/middleware/validatedRequest");
+
+function importedAgentPluginEndpoints(app) {
+ if (!app) return;
+
+ app.post(
+ "/experimental/agent-plugins/:hubId/toggle",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ (request, response) => {
+ try {
+ const { hubId } = request.params;
+ const { active } = reqBody(request);
+ const updatedConfig = ImportedPlugin.updateImportedPlugin(hubId, {
+ active: Boolean(active),
+ });
+ response.status(200).json(updatedConfig);
+ } catch (e) {
+ console.error(e);
+ response.status(500).end();
+ }
+ }
+ );
+
+ app.post(
+ "/experimental/agent-plugins/:hubId/config",
+ [validatedRequest, flexUserRoleValid([ROLES.admin])],
+ (request, response) => {
+ try {
+ const { hubId } = request.params;
+ const { updates } = reqBody(request);
+ const updatedConfig = ImportedPlugin.updateImportedPlugin(
+ hubId,
+ updates
+ );
+ response.status(200).json(updatedConfig);
+ } catch (e) {
+ console.error(e);
+ response.status(500).end();
+ }
+ }
+ );
+}
+
+module.exports = { importedAgentPluginEndpoints };
diff --git a/server/endpoints/experimental/index.js b/server/endpoints/experimental/index.js
index e7dd144c5b0..cc390811a9b 100644
--- a/server/endpoints/experimental/index.js
+++ b/server/endpoints/experimental/index.js
@@ -1,5 +1,6 @@
const { fineTuningEndpoints } = require("./fineTuning");
const { liveSyncEndpoints } = require("./liveSync");
+const { importedAgentPluginEndpoints } = require("./imported-agent-plugins");
// All endpoints here are not stable and can move around - have breaking changes
// or are opt-in features that are not fully released.
@@ -7,6 +8,7 @@ const { liveSyncEndpoints } = require("./liveSync");
function experimentalEndpoints(router) {
liveSyncEndpoints(router);
fineTuningEndpoints(router);
+ importedAgentPluginEndpoints(router);
}
module.exports = { experimentalEndpoints };
diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js
index e9ae3f3e961..c2c03ffa099 100644
--- a/server/models/systemSettings.js
+++ b/server/models/systemSettings.js
@@ -15,6 +15,23 @@ function isNullOrNaN(value) {
const SystemSettings = {
protectedFields: ["multi_user_mode"],
+ publicFields: [
+ "limit_user_messages",
+ "message_limit",
+ "footer_data",
+ "support_email",
+ "text_splitter_chunk_size",
+ "text_splitter_chunk_overlap",
+ "max_embed_chunk_size",
+ "agent_search_provider",
+ "agent_sql_connections",
+ "default_agent_skills",
+ "imported_agent_skills",
+ "custom_app_name",
+ "feature_flags",
+ "meta_page_title",
+ "meta_page_favicon",
+ ],
supportedFields: [
"limit_user_messages",
"message_limit",
diff --git a/server/utils/agents/aibitat/index.js b/server/utils/agents/aibitat/index.js
index 0d3aab1add4..56da25eb1dd 100644
--- a/server/utils/agents/aibitat/index.js
+++ b/server/utils/agents/aibitat/index.js
@@ -504,9 +504,13 @@ Only return the role.
* @param {string} pluginName this name of the plugin being called
* @returns string of the plugin to be called compensating for children denoted by # in the string.
* eg: sql-agent:list-database-connections
+ * or is a custom plugin
+ * eg: @@custom-plugin-name
*/
#parseFunctionName(pluginName = "") {
- if (!pluginName.includes("#")) return pluginName;
+ if (!pluginName.includes("#") && !pluginName.startsWith("@@"))
+ return pluginName;
+ if (pluginName.startsWith("@@")) return pluginName.replace("@@", "");
return pluginName.split("#")[1];
}
diff --git a/server/utils/agents/defaults.js b/server/utils/agents/defaults.js
index a6d30ca15b1..6154fab6672 100644
--- a/server/utils/agents/defaults.js
+++ b/server/utils/agents/defaults.js
@@ -2,6 +2,7 @@ const AgentPlugins = require("./aibitat/plugins");
const { SystemSettings } = require("../../models/systemSettings");
const { safeJsonParse } = require("../http");
const Provider = require("./aibitat/providers/ai-provider");
+const ImportedPlugin = require("./imported");
const USER_AGENT = {
name: "USER",
@@ -27,6 +28,7 @@ const WORKSPACE_AGENT = {
functions: [
...defaultFunctions,
...(await agentSkillsFromSystemSettings()),
+ ...(await ImportedPlugin.activeImportedPlugins()),
],
};
},
diff --git a/server/utils/agents/imported.js b/server/utils/agents/imported.js
new file mode 100644
index 00000000000..136f4a3ad1d
--- /dev/null
+++ b/server/utils/agents/imported.js
@@ -0,0 +1,176 @@
+const fs = require("fs");
+const path = require("path");
+const { safeJsonParse } = require("../http");
+const { isWithin, normalizePath } = require("../files");
+const pluginsPath =
+ process.env.NODE_ENV === "development"
+ ? path.resolve(__dirname, "../../storage/plugins/agent-skills")
+ : path.resolve(process.env.STORAGE_DIR, "plugins", "agent-skills");
+
+class ImportedPlugin {
+ constructor(config) {
+ this.config = config;
+ this.handlerLocation = path.resolve(
+ pluginsPath,
+ this.config.hubId,
+ "handler.js"
+ );
+ delete require.cache[require.resolve(this.handlerLocation)];
+ this.handler = require(this.handlerLocation);
+ this.name = config.hubId;
+ this.startupConfig = {
+ params: {},
+ };
+ }
+
+ /**
+ * Gets the imported plugin handler.
+ * @param {string} hubId - The hub ID of the plugin.
+ * @returns {ImportedPlugin} - The plugin handler.
+ */
+ static loadPluginByHubId(hubId) {
+ const configLocation = path.resolve(
+ pluginsPath,
+ normalizePath(hubId),
+ "plugin.json"
+ );
+ if (!this.isValidLocation(configLocation)) return;
+ const config = safeJsonParse(fs.readFileSync(configLocation, "utf8"));
+ return new ImportedPlugin(config);
+ }
+
+ static isValidLocation(pathToValidate) {
+ if (!isWithin(pluginsPath, pathToValidate)) return false;
+ if (!fs.existsSync(pathToValidate)) return false;
+ return true;
+ }
+
+ /**
+ * Loads plugins from `plugins` folder in storage that are custom loaded and defined.
+ * only loads plugins that are active: true.
+ * @returns {Promise} - array of plugin names to be loaded later.
+ */
+ static async activeImportedPlugins() {
+ const plugins = [];
+ const folders = fs.readdirSync(path.resolve(pluginsPath));
+ for (const folder of folders) {
+ const configLocation = path.resolve(
+ pluginsPath,
+ normalizePath(folder),
+ "plugin.json"
+ );
+ if (!this.isValidLocation(configLocation)) continue;
+ const config = safeJsonParse(fs.readFileSync(configLocation, "utf8"));
+ if (config.active) plugins.push(`@@${config.hubId}`);
+ }
+ return plugins;
+ }
+
+ /**
+ * Lists all imported plugins.
+ * @returns {Array} - array of plugin configurations (JSON).
+ */
+ static listImportedPlugins() {
+ const plugins = [];
+ if (!fs.existsSync(pluginsPath)) return plugins;
+
+ const folders = fs.readdirSync(path.resolve(pluginsPath));
+ for (const folder of folders) {
+ const configLocation = path.resolve(
+ pluginsPath,
+ normalizePath(folder),
+ "plugin.json"
+ );
+ if (!this.isValidLocation(configLocation)) continue;
+ const config = safeJsonParse(fs.readFileSync(configLocation, "utf8"));
+ plugins.push(config);
+ }
+ return plugins;
+ }
+
+ /**
+ * Updates a plugin configuration.
+ * @param {string} hubId - The hub ID of the plugin.
+ * @param {object} config - The configuration to update.
+ * @returns {object} - The updated configuration.
+ */
+ static updateImportedPlugin(hubId, config) {
+ const configLocation = path.resolve(
+ pluginsPath,
+ normalizePath(hubId),
+ "plugin.json"
+ );
+ if (!this.isValidLocation(configLocation)) return;
+
+ const currentConfig = safeJsonParse(
+ fs.readFileSync(configLocation, "utf8"),
+ null
+ );
+ if (!currentConfig) return;
+
+ const updatedConfig = { ...currentConfig, ...config };
+ fs.writeFileSync(configLocation, JSON.stringify(updatedConfig, null, 2));
+ return updatedConfig;
+ }
+
+ /**
+ * Validates if the handler.js file exists for the given plugin.
+ * @param {string} hubId - The hub ID of the plugin.
+ * @returns {boolean} - True if the handler.js file exists, false otherwise.
+ */
+ static validateImportedPluginHandler(hubId) {
+ const handlerLocation = path.resolve(
+ pluginsPath,
+ normalizePath(hubId),
+ "handler.js"
+ );
+ return this.isValidLocation(handlerLocation);
+ }
+
+ parseCallOptions() {
+ const callOpts = {};
+ if (!this.config.setup_args || typeof this.config.setup_args !== "object") {
+ return callOpts;
+ }
+ for (const [param, definition] of Object.entries(this.config.setup_args)) {
+ if (definition.required && !definition?.value) {
+ console.log(
+ `'${param}' required value for '${this.name}' plugin is missing. Plugin may not function or crash agent.`
+ );
+ continue;
+ }
+ callOpts[param] = definition.value || definition.default || null;
+ }
+ return callOpts;
+ }
+
+ plugin(runtimeArgs = {}) {
+ const customFunctions = this.handler.runtime;
+ return {
+ runtimeArgs,
+ name: this.name,
+ config: this.config,
+ setup(aibitat) {
+ aibitat.function({
+ super: aibitat,
+ name: this.name,
+ config: this.config,
+ runtimeArgs: this.runtimeArgs,
+ description: this.config.description,
+ logger: aibitat?.handlerProps?.log || console.log, // Allows plugin to log to the console.
+ introspect: aibitat?.introspect || console.log, // Allows plugin to display a "thought" the chat window UI.
+ examples: this.config.examples ?? [],
+ parameters: {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: this.config.entrypoint.params ?? {},
+ additionalProperties: false,
+ },
+ ...customFunctions,
+ });
+ },
+ };
+ }
+}
+
+module.exports = ImportedPlugin;
diff --git a/server/utils/agents/index.js b/server/utils/agents/index.js
index 86563d1850f..521b9e9cad1 100644
--- a/server/utils/agents/index.js
+++ b/server/utils/agents/index.js
@@ -6,6 +6,7 @@ const {
const { WorkspaceChats } = require("../../models/workspaceChats");
const { safeJsonParse } = require("../http");
const { USER_AGENT, WORKSPACE_AGENT } = require("./defaults");
+const ImportedPlugin = require("./imported");
class AgentHandler {
#invocationUUID;
@@ -292,6 +293,27 @@ class AgentHandler {
continue;
}
+ // Load imported plugin. This is marked by `@@` in the array of functions to load.
+ // and is the @@hubID of the plugin.
+ if (name.startsWith("@@")) {
+ const hubId = name.replace("@@", "");
+ const valid = ImportedPlugin.validateImportedPluginHandler(hubId);
+ if (!valid) {
+ this.log(
+ `Imported plugin by hubId ${hubId} not found in plugin directory. Skipping inclusion to agent cluster.`
+ );
+ continue;
+ }
+
+ const plugin = ImportedPlugin.loadPluginByHubId(hubId);
+ const callOpts = plugin.parseCallOptions();
+ this.aibitat.use(plugin.plugin(callOpts));
+ this.log(
+ `Attached ${plugin.name} (${hubId}) imported plugin to Agent cluster`
+ );
+ continue;
+ }
+
// Load single-stage plugin.
if (!AgentPlugins.hasOwnProperty(name)) {
this.log(
diff --git a/server/utils/http/index.js b/server/utils/http/index.js
index e812b8abd77..7e76f327d9a 100644
--- a/server/utils/http/index.js
+++ b/server/utils/http/index.js
@@ -64,6 +64,8 @@ function parseAuthHeader(headerValue = null, apiKey = null) {
}
function safeJsonParse(jsonString, fallback = null) {
+ if (jsonString === null) return fallback;
+
try {
return JSON.parse(jsonString);
} catch {}