diff --git a/.github/workflows/dev-build.yaml b/.github/workflows/dev-build.yaml index 6d938af8ee9..86e10bbcd98 100644 --- a/.github/workflows/dev-build.yaml +++ b/.github/workflows/dev-build.yaml @@ -6,7 +6,7 @@ concurrency: on: push: - branches: ['chrome-extension'] # put your current branch to create a build. Core team only. + branches: ['agent-skill-plugins'] # put your current branch to create a build. Core team only. paths-ignore: - '**.md' - 'cloud-deployments/*' diff --git a/frontend/src/models/admin.js b/frontend/src/models/admin.js index c8fe2cc9786..336e98789b4 100644 --- a/frontend/src/models/admin.js +++ b/frontend/src/models/admin.js @@ -156,6 +156,8 @@ const Admin = { }, // System Preferences + // TODO: remove this in favor of systemPreferencesByFields + // DEPRECATED: use systemPreferencesByFields instead systemPreferences: async () => { return await fetch(`${API_BASE}/admin/system-preferences`, { method: "GET", @@ -167,6 +169,26 @@ const Admin = { return null; }); }, + + /** + * Fetches system preferences by fields + * @param {string[]} labels - Array of labels for settings + * @returns {Promise<{settings: Object, error: string}>} - System preferences object + */ + systemPreferencesByFields: async (labels = []) => { + return await fetch( + `${API_BASE}/admin/system-preferences-for?labels=${labels.join(",")}`, + { + method: "GET", + headers: baseHeaders(), + } + ) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return null; + }); + }, updateSystemPreferences: async (updates = {}) => { return await fetch(`${API_BASE}/admin/system-preferences`, { method: "POST", diff --git a/frontend/src/models/experimental/agentPlugins.js b/frontend/src/models/experimental/agentPlugins.js new file mode 100644 index 00000000000..9a544d5f12f --- /dev/null +++ b/frontend/src/models/experimental/agentPlugins.js @@ -0,0 +1,43 @@ +import { API_BASE } from "@/utils/constants"; +import { baseHeaders } from "@/utils/request"; + +const AgentPlugins = { + toggleFeature: async function (hubId, active = false) { + return await fetch( + `${API_BASE}/experimental/agent-plugins/${hubId}/toggle`, + { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ active }), + } + ) + .then((res) => { + if (!res.ok) throw new Error("Could not update agent plugin status."); + return true; + }) + .catch((e) => { + console.error(e); + return false; + }); + }, + updatePluginConfig: async function (hubId, updates = {}) { + return await fetch( + `${API_BASE}/experimental/agent-plugins/${hubId}/config`, + { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ updates }), + } + ) + .then((res) => { + if (!res.ok) throw new Error("Could not update agent plugin config."); + return true; + }) + .catch((e) => { + console.error(e); + return false; + }); + }, +}; + +export default AgentPlugins; diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index 095244a4858..cb2f34e023c 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -2,6 +2,7 @@ import { API_BASE, AUTH_TIMESTAMP, fullApiUrl } from "@/utils/constants"; import { baseHeaders, safeJsonParse } from "@/utils/request"; import DataConnector from "./dataConnector"; import LiveDocumentSync from "./experimental/liveSync"; +import AgentPlugins from "./experimental/agentPlugins"; const System = { cacheKeys: { @@ -675,6 +676,7 @@ const System = { }, experimentalFeatures: { liveSync: LiveDocumentSync, + agentPlugins: AgentPlugins, }, }; diff --git a/frontend/src/pages/Admin/Agents/Imported/ImportedSkillConfig/index.jsx b/frontend/src/pages/Admin/Agents/Imported/ImportedSkillConfig/index.jsx new file mode 100644 index 00000000000..644d931d26b --- /dev/null +++ b/frontend/src/pages/Admin/Agents/Imported/ImportedSkillConfig/index.jsx @@ -0,0 +1,180 @@ +import System from "@/models/system"; +import showToast from "@/utils/toast"; +import { Plug } from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { sentenceCase } from "text-case"; + +/** + * Converts setup_args to inputs for the form builder + * @param {object} setupArgs - The setup arguments object + * @returns {object} - The inputs object + */ +function inputsFromArgs(setupArgs) { + if ( + !setupArgs || + setupArgs.constructor?.call?.().toString() !== "[object Object]" + ) { + return {}; + } + return Object.entries(setupArgs).reduce( + (acc, [key, props]) => ({ + ...acc, + [key]: props.hasOwnProperty("value") + ? props.value + : props?.input?.default || "", + }), + {} + ); +} + +/** + * Imported skill config component for imported skills only. + * @returns {JSX.Element} + */ +export default function ImportedSkillConfig({ + selectedSkill, // imported skill config object + setImportedSkills, // function to set imported skills since config is file-write +}) { + const [config, setConfig] = useState(selectedSkill); + const [hasChanges, setHasChanges] = useState(false); + const [inputs, setInputs] = useState( + inputsFromArgs(selectedSkill?.setup_args) + ); + + const hasSetupArgs = + selectedSkill?.setup_args && + Object.keys(selectedSkill.setup_args).length > 0; + + async function toggleSkill() { + const updatedConfig = { ...selectedSkill, active: !config.active }; + await System.experimentalFeatures.agentPlugins.updatePluginConfig( + config.hubId, + { active: !config.active } + ); + setImportedSkills((prev) => + prev.map((s) => (s.hubId === config.hubId ? updatedConfig : s)) + ); + setConfig(updatedConfig); + } + + async function handleSubmit(e) { + e.preventDefault(); + const errors = []; + const updatedConfig = { ...config }; + + for (const [key, value] of Object.entries(inputs)) { + const settings = config.setup_args[key]; + if (settings.required && !value) { + errors.push(`${key} is required to have a value.`); + continue; + } + if (typeof value !== settings.type) { + errors.push(`${key} must be of type ${settings.type}.`); + continue; + } + updatedConfig.setup_args[key].value = value; + } + + if (errors.length > 0) { + errors.forEach((error) => showToast(error, "error")); + return; + } + + await System.experimentalFeatures.agentPlugins.updatePluginConfig( + config.hubId, + updatedConfig + ); + setConfig(updatedConfig); + setImportedSkills((prev) => + prev.map((skill) => + skill.hubId === config.hubId ? updatedConfig : skill + ) + ); + showToast("Skill config updated successfully.", "success"); + } + + useEffect(() => { + setHasChanges( + JSON.stringify(inputs) !== + JSON.stringify(inputsFromArgs(selectedSkill.setup_args)) + ); + }, [inputs]); + + return ( + <> +
+
+
+ + + +
+

+ {config.description} by{" "} + + {config.author} + +

+ + {hasSetupArgs ? ( +
+ {Object.entries(config.setup_args).map(([key, props]) => ( +
+ + + setInputs({ ...inputs, [key]: e.target.value }) + } + placeholder={props?.input?.placeholder || ""} + className="bg-transparent border border-white border-opacity-20 rounded-md p-2 text-white text-sm" + /> +

+ {props?.input?.hint} +

+
+ ))} + {hasChanges && ( + + )} +
+ ) : ( +

+ There are no options to modify for this skill. +

+ )} +
+
+ + ); +} diff --git a/frontend/src/pages/Admin/Agents/Imported/SkillList/index.jsx b/frontend/src/pages/Admin/Agents/Imported/SkillList/index.jsx new file mode 100644 index 00000000000..b6358077ac9 --- /dev/null +++ b/frontend/src/pages/Admin/Agents/Imported/SkillList/index.jsx @@ -0,0 +1,59 @@ +import { CaretRight } from "@phosphor-icons/react"; +import { isMobile } from "react-device-detect"; +import { sentenceCase } from "text-case"; + +export default function ImportedSkillList({ + skills = [], + selectedSkill = null, + handleClick = null, +}) { + if (skills.length === 0) + return ( +
+

No imported skills found

+

+ Learn about agent skills in the{" "} + + AnythingLLM Agent Docs + + . +

+
+ ); + + return ( +
+ {skills.map((config, index) => ( +
handleClick?.({ ...config, imported: true })} + > +
{sentenceCase(config.name)}
+
+
+ {config.active ? "On" : "Off"} +
+ +
+
+ ))} +
+ ); +} diff --git a/frontend/src/pages/Admin/Agents/index.jsx b/frontend/src/pages/Admin/Agents/index.jsx index 9cb1a93ae71..99c093d5cb2 100644 --- a/frontend/src/pages/Admin/Agents/index.jsx +++ b/frontend/src/pages/Admin/Agents/index.jsx @@ -4,18 +4,21 @@ import { isMobile } from "react-device-detect"; import Admin from "@/models/admin"; import System from "@/models/system"; import showToast from "@/utils/toast"; -import { CaretLeft, CaretRight, Robot } from "@phosphor-icons/react"; +import { CaretLeft, CaretRight, Plug, Robot } from "@phosphor-icons/react"; import ContextualSaveBar from "@/components/ContextualSaveBar"; import { castToType } from "@/utils/types"; import { FullScreenLoader } from "@/components/Preloader"; import { defaultSkills, configurableSkills } from "./skills"; import { DefaultBadge } from "./Badges/default"; +import ImportedSkillList from "./Imported/SkillList"; +import ImportedSkillConfig from "./Imported/ImportedSkillConfig"; export default function AdminAgents() { const [hasChanges, setHasChanges] = useState(false); const [settings, setSettings] = useState({}); const [selectedSkill, setSelectedSkill] = useState(""); const [agentSkills, setAgentSkills] = useState([]); + const [importedSkills, setImportedSkills] = useState([]); const [loading, setLoading] = useState(true); const [showSkillModal, setShowSkillModal] = useState(false); const formEl = useRef(null); @@ -37,9 +40,13 @@ export default function AdminAgents() { useEffect(() => { async function fetchSettings() { const _settings = await System.keys(); - const _preferences = await Admin.systemPreferences(); + const _preferences = await Admin.systemPreferencesByFields([ + "default_agent_skills", + "imported_agent_skills", + ]); setSettings({ ..._settings, preferences: _preferences.settings } ?? {}); setAgentSkills(_preferences.settings?.default_agent_skills ?? []); + setImportedSkills(_preferences.settings?.imported_agent_skills ?? []); setLoading(false); } fetchSettings(); @@ -84,9 +91,13 @@ export default function AdminAgents() { if (success) { const _settings = await System.keys(); - const _preferences = await Admin.systemPreferences(); + const _preferences = await Admin.systemPreferencesByFields([ + "default_agent_skills", + "imported_agent_skills", + ]); setSettings({ ..._settings, preferences: _preferences.settings } ?? {}); setAgentSkills(_preferences.settings?.default_agent_skills ?? []); + setImportedSkills(_preferences.settings?.imported_agent_skills ?? []); showToast(`Agent preferences saved successfully.`, "success", { clear: true, }); @@ -97,9 +108,10 @@ export default function AdminAgents() { setHasChanges(false); }; - const SelectedSkillComponent = - configurableSkills[selectedSkill]?.component || - defaultSkills[selectedSkill]?.component; + const SelectedSkillComponent = selectedSkill.imported + ? ImportedSkillConfig + : configurableSkills[selectedSkill]?.component || + defaultSkills[selectedSkill]?.component; if (loading) { return ( @@ -157,6 +169,16 @@ export default function AdminAgents() { }} activeSkills={agentSkills} /> + +
+ +

Custom Skills

+
+ {/* Selected agent skill modal */} @@ -181,17 +203,27 @@ export default function AdminAgents() {
{SelectedSkillComponent ? ( - + {selectedSkill.imported ? ( + + ) : ( + )} - setHasChanges={setHasChanges} - {...(configurableSkills[selectedSkill] || - defaultSkills[selectedSkill])} - /> + ) : (
@@ -216,7 +248,7 @@ export default function AdminAgents() { >
setHasChanges(true)} + onChange={() => !selectedSkill.imported && setHasChanges(true)} ref={formEl} className="flex-1 flex gap-x-6 p-4 mt-10" > @@ -247,23 +279,43 @@ export default function AdminAgents() { handleClick={setSelectedSkill} activeSkills={agentSkills} /> + +
+ +

Custom Skills

+
+
{/* Selected agent skill setting panel */}
{SelectedSkillComponent ? ( - + {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 {}