Add support for custom agent skills via plugins ()

* Add support for custom agent skills via plugins
Update Admin.systemPreferences to updated endpoint (legacy has deprecation notice

* lint

* dev build

* patch safeJson
patch label loading

* allow plugins with no config options

* lint

* catch invalid setupArgs in frontend

* update link to docs page for agent skills

* remove unneeded files

---------

Co-authored-by: shatfield4 <seanhatfield5@gmail.com>
This commit is contained in:
Timothy Carambat 2024-09-10 17:06:02 -07:00 committed by GitHub
parent f3f6299aae
commit d1103e2b71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 768 additions and 30 deletions
.github/workflows
frontend/src
models
pages/Admin/Agents
Imported
ImportedSkillConfig
SkillList
index.jsx
server

View file

@ -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/*'

View file

@ -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",

View file

@ -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;

View file

@ -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,
},
};

View file

@ -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 (
<>
<div className="p-2">
<div className="flex flex-col gap-y-[18px] max-w-[500px]">
<div className="flex items-center gap-x-2">
<Plug size={24} color="white" weight="bold" />
<label htmlFor="name" className="text-white text-md font-bold">
{sentenceCase(config.name)}
</label>
<label className="border-none relative inline-flex cursor-pointer items-center ml-auto">
<input
type="checkbox"
className="peer sr-only"
checked={config.active}
onChange={() => toggleSkill()}
/>
<div className="pointer-events-none peer h-6 w-11 rounded-full bg-stone-400 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:shadow-xl after:border after:border-gray-600 after:bg-white after:box-shadow-md after:transition-all after:content-[''] peer-checked:bg-lime-300 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800"></div>
<span className="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"></span>
</label>
</div>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
{config.description} by{" "}
<a
href={config.author_url}
target="_blank"
rel="noopener noreferrer"
className="text-white hover:underline"
>
{config.author}
</a>
</p>
{hasSetupArgs ? (
<div className="flex flex-col gap-y-2">
{Object.entries(config.setup_args).map(([key, props]) => (
<div key={key} className="flex flex-col gap-y-1">
<label htmlFor={key} className="text-white text-sm font-bold">
{key}
</label>
<input
type={props?.input?.type || "text"}
required={props?.input?.required}
defaultValue={
props.hasOwnProperty("value")
? props.value
: props?.input?.default || ""
}
onChange={(e) =>
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"
/>
<p className="text-white text-opacity-60 text-xs font-medium py-1.5">
{props?.input?.hint}
</p>
</div>
))}
{hasChanges && (
<button
onClick={handleSubmit}
type="button"
className="bg-blue-500 text-white rounded-md p-2"
>
Save
</button>
)}
</div>
) : (
<p className="text-white text-opacity-60 text-sm font-medium py-1.5">
There are no options to modify for this skill.
</p>
)}
</div>
</div>
</>
);
}

View file

@ -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 (
<div className="text-white/60 text-center text-xs flex flex-col gap-y-2">
<p>No imported skills found</p>
<p>
Learn about agent skills in the{" "}
<a
href="https://docs.anythingllm.com/agent/custom/developer-guide"
target="_blank"
className="text-white/80 hover:underline"
>
AnythingLLM Agent Docs
</a>
.
</p>
</div>
);
return (
<div
className={`bg-white/5 text-white rounded-xl ${
isMobile ? "w-full" : "min-w-[360px] w-fit"
}`}
>
{skills.map((config, index) => (
<div
key={config.hubId}
className={`py-3 px-4 flex items-center justify-between ${
index === 0 ? "rounded-t-xl" : ""
} ${
index === Object.keys(skills).length - 1
? "rounded-b-xl"
: "border-b border-white/10"
} cursor-pointer transition-all duration-300 hover:bg-white/5 ${
selectedSkill === config.hubId ? "bg-white/10" : ""
}`}
onClick={() => handleClick?.({ ...config, imported: true })}
>
<div className="text-sm font-light">{sentenceCase(config.name)}</div>
<div className="flex items-center gap-x-2">
<div className="text-sm text-white/60 font-medium">
{config.active ? "On" : "Off"}
</div>
<CaretRight size={14} weight="bold" className="text-white/80" />
</div>
</div>
))}
</div>
);
}

View file

@ -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}
/>
<div className="text-white flex items-center gap-x-2">
<Plug size={24} />
<p className="text-lg font-medium">Custom Skills</p>
</div>
<ImportedSkillList
skills={importedSkills}
selectedSkill={selectedSkill}
handleClick={setSelectedSkill}
/>
</div>
{/* Selected agent skill modal */}
@ -181,17 +203,27 @@ export default function AdminAgents() {
<div className="flex-1 overflow-y-auto p-4">
<div className="bg-[#303237] text-white rounded-xl p-4">
{SelectedSkillComponent ? (
<SelectedSkillComponent
skill={configurableSkills[selectedSkill]?.skill}
settings={settings}
toggleSkill={toggleAgentSkill}
enabled={agentSkills.includes(
configurableSkills[selectedSkill]?.skill
<>
{selectedSkill.imported ? (
<ImportedSkillConfig
key={selectedSkill.hubId}
selectedSkill={selectedSkill}
setImportedSkills={setImportedSkills}
/>
) : (
<SelectedSkillComponent
skill={configurableSkills[selectedSkill]?.skill}
settings={settings}
toggleSkill={toggleAgentSkill}
enabled={agentSkills.includes(
configurableSkills[selectedSkill]?.skill
)}
setHasChanges={setHasChanges}
{...(configurableSkills[selectedSkill] ||
defaultSkills[selectedSkill])}
/>
)}
setHasChanges={setHasChanges}
{...(configurableSkills[selectedSkill] ||
defaultSkills[selectedSkill])}
/>
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-white/60">
<Robot size={40} />
@ -216,7 +248,7 @@ export default function AdminAgents() {
>
<form
onSubmit={handleSubmit}
onChange={() => 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}
/>
<div className="text-white flex items-center gap-x-2">
<Plug size={24} />
<p className="text-lg font-medium">Custom Skills</p>
</div>
<ImportedSkillList
skills={importedSkills}
selectedSkill={selectedSkill}
handleClick={setSelectedSkill}
/>
</div>
{/* Selected agent skill setting panel */}
<div className="flex-[2] flex flex-col gap-y-[18px] mt-10">
<div className="bg-[#303237] text-white rounded-xl flex-1 p-4">
{SelectedSkillComponent ? (
<SelectedSkillComponent
skill={configurableSkills[selectedSkill]?.skill}
settings={settings}
toggleSkill={toggleAgentSkill}
enabled={agentSkills.includes(
configurableSkills[selectedSkill]?.skill
<>
{selectedSkill.imported ? (
<ImportedSkillConfig
key={selectedSkill.hubId}
selectedSkill={selectedSkill}
setImportedSkills={setImportedSkills}
/>
) : (
<SelectedSkillComponent
skill={configurableSkills[selectedSkill]?.skill}
settings={settings}
toggleSkill={toggleAgentSkill}
enabled={agentSkills.includes(
configurableSkills[selectedSkill]?.skill
)}
setHasChanges={setHasChanges}
{...(configurableSkills[selectedSkill] ||
defaultSkills[selectedSkill])}
/>
)}
setHasChanges={setHasChanges}
{...(configurableSkills[selectedSkill] ||
defaultSkills[selectedSkill])}
/>
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-white/60">
<Robot size={40} />

1
server/.gitignore vendored
View file

@ -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

View file

@ -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,

View file

@ -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 };

View file

@ -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 };

View file

@ -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",

View file

@ -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];
}

View file

@ -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()),
],
};
},

View file

@ -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<string[]>} - 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;

View file

@ -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(

View file

@ -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 {}