mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🎯 feat: Per-Agent Skill Selection in Builder and Runtime Scoping (#12689)
* feat: per-agent skill selection in builder and runtime scoping
Wire skills persistence on the Agent model and enable the skills
section in the agents builder panel. At runtime, scope the skill
catalog to only the skills configured on each agent (intersected
with user ACL). When no skills are configured, the full user catalog
is used as the default. The ephemeral chat toggle overrides per-agent
scoping to provide the full catalog.
* fix: add scopeSkillIds to @librechat/api mock in responses unit test
The test mocks @librechat/api but was missing the newly imported
scopeSkillIds, causing createResponse to throw before reaching the
assertions. Added a passthrough mock that returns the input array.
* fix: scope primeInvokedSkills by agent's configured skills
primeInvokedSkills was receiving the full unscoped accessibleSkillIds,
bypassing the per-agent skill scoping applied to initializeAgent. This
allowed previously invoked skills from message history to be resolved
and primed even when excluded from the agent's configured skill set.
Apply the same scopeSkillIds filtering to match the initializeAgent
calls, so skill resolution is consistent across catalog injection
and history priming.
* fix: preserve agent skills through form reset and union prime scope
Two related bugs in the per-agent skill selection flow:
1. resetAgentForm dropped the persisted skills array because the generic
fall-through at the end of the loop excludes object/array values.
Combined with composeAgentUpdatePayload always emitting skills, this
caused any save of a previously-configured agent to silently overwrite
skills with an empty array. Add an explicit case for skills mirroring
the agent_ids handling.
2. primeInvokedSkills processes the full conversation payload, including
prior handoff-agent invocations. Scoping it to only primaryAgent.skills
meant a skill invoked by a handoff agent in a prior turn could not be
resolved when the current primary agent had a different scope, leaving
message history reconstruction incomplete. Union the per-agent scoped
accessibleSkillIds across primary plus all loaded handoff agents so
any skill any active agent could invoke is resolvable from history.
* fix: mark inline skill removals as dirty
The inline X button on the skills list called setValue without
shouldDirty: true, so removing a skill via this control did not
mark the skills field as dirty in react-hook-form state. When a
user removed a skill with the X button and also staged an avatar
upload in the same save, isAvatarUploadOnlyDirty returned true and
onSubmit short-circuited to avatar-only upload, silently dropping
the PATCH that would persist the skill removal.
The dialog path (SkillSelectDialog) already passes shouldDirty: true
on add/remove; this aligns the inline control with that behavior.
* fix: restore full ACL scope for primeInvokedSkills history reconstruction
Reverting the earlier scoping of primeInvokedSkills to the active-agent
union. That change conflated runtime invocation scoping (which correctly
gates what the model can call now) with history reconstruction (which
restores bodies the model already saw in prior turns).
Per-agent scoping still applies at:
- Catalog injection (injectSkillCatalog via initializeAgent)
- Runtime invocation (handleSkillToolCall via enrichWithSkillConfigurable,
using each agent's scoped accessibleSkillIds in agentToolContexts)
History priming is a read of past context, not a grant of new capability.
Scoping it causes historical skill bodies to vanish from formatAgentMessages
when an agent's skills list is edited mid-conversation or when the ephemeral
toggle flips, which breaks message reconstruction and drops code-env file
continuity for /mnt/data/{skillName}/ references. The user's ACL-accessible
set is the correct and sufficient gate for history reconstruction.
* fix: close openai.js skill gap and pin undefined vs [] semantics
Three related gaps surfaced in review:
1. api/server/controllers/agents/openai.js was a third skill resolution
site alongside responses.js and initialize.js, but still used the old
activation gate (required ephemeralAgent.skills === true) and never
passed accessibleSkillIds through scopeSkillIds. Per-agent scoping
silently did not apply on this route. Mirror the same pattern used
in responses.js so all three routes behave identically.
2. scopeSkillIds previously collapsed undefined and [] into the same
"full catalog" fallback, making it impossible for a user to express
"this agent has no skills." Tighten the semantics before any data
is written under the old behavior:
- undefined / null = not configured, full catalog
- [] = explicitly none, returns []
- non-empty = intersection with ACL-accessible set
Update defaultAgentFormValues.skills from [] to undefined so a brand
new agent whose skills UI was never touched does not accidentally
persist "explicit none" on first save (removeNullishValues strips
undefined from the payload server side).
3. Add direct unit tests for scopeSkillIds covering all five cases
(undefined, null, empty, disjoint, overlap, exact match, empty
accessible set). 16 tests total in skills.test.ts pass.
* fix: add scopeSkillIds to @librechat/api mock in openai unit test
Same pattern as the earlier responses.unit.spec.js fix: the test mocks
@librechat/api with an explicit object, so each newly imported symbol
must be added to the mock. Without scopeSkillIds, OpenAIChatCompletion
controller throws on destructuring before reaching recordCollectedUsage,
causing the token usage assertions to fail.
This commit is contained in:
parent
9b4ae068b2
commit
3e064c2f2b
15 changed files with 178 additions and 37 deletions
|
|
@ -40,6 +40,7 @@ jest.mock('@librechat/api', () => ({
|
|||
}),
|
||||
createChunk: jest.fn().mockReturnValue({}),
|
||||
buildToolSet: jest.fn().mockReturnValue(new Set()),
|
||||
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
|
||||
sendFinalChunk: jest.fn(),
|
||||
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
|
||||
validateRequest: jest
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ jest.mock('@librechat/api', () => ({
|
|||
processStream: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
buildToolSet: jest.fn().mockReturnValue(new Set()),
|
||||
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
|
||||
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
|
||||
initializeAgent: jest.fn().mockResolvedValue({
|
||||
id: 'agent-123',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const {
|
|||
createRun,
|
||||
createChunk,
|
||||
buildToolSet,
|
||||
scopeSkillIds,
|
||||
sendFinalChunk,
|
||||
createSafeUser,
|
||||
validateRequest,
|
||||
|
|
@ -244,10 +245,9 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
};
|
||||
|
||||
const enabledCapabilities = new Set(agentsEConfig?.capabilities);
|
||||
const ephemeralAgent = req.body?.ephemeralAgent;
|
||||
const skillsEnabled =
|
||||
enabledCapabilities.has(AgentCapabilities.skills) && ephemeralAgent?.skills === true;
|
||||
const accessibleSkillIds = skillsEnabled
|
||||
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
|
||||
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
|
||||
const accessibleSkillIds = skillsCapabilityEnabled
|
||||
? await findAccessibleResources({
|
||||
userId: req.user.id,
|
||||
role: req.user.role,
|
||||
|
|
@ -268,7 +268,10 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
endpointOption,
|
||||
allowedProviders,
|
||||
isInitialAgent: true,
|
||||
accessibleSkillIds,
|
||||
accessibleSkillIds: scopeSkillIds(
|
||||
accessibleSkillIds,
|
||||
ephemeralSkillsToggle ? undefined : agent.skills,
|
||||
),
|
||||
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
|
||||
},
|
||||
dbMethods,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const {
|
|||
const {
|
||||
createRun,
|
||||
buildToolSet,
|
||||
scopeSkillIds,
|
||||
createSafeUser,
|
||||
initializeAgent,
|
||||
getBalanceConfig,
|
||||
|
|
@ -373,10 +374,9 @@ const createResponse = async (req, res) => {
|
|||
const enabledCapabilities = new Set(
|
||||
appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities,
|
||||
);
|
||||
const ephemeralAgent = req.body?.ephemeralAgent;
|
||||
const skillsEnabled =
|
||||
enabledCapabilities.has(AgentCapabilities.skills) && ephemeralAgent?.skills === true;
|
||||
const accessibleSkillIds = skillsEnabled
|
||||
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
|
||||
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
|
||||
const accessibleSkillIds = skillsCapabilityEnabled
|
||||
? await findAccessibleResources({
|
||||
userId: req.user.id,
|
||||
role: req.user.role,
|
||||
|
|
@ -397,7 +397,10 @@ const createResponse = async (req, res) => {
|
|||
endpointOption,
|
||||
allowedProviders,
|
||||
isInitialAgent: true,
|
||||
accessibleSkillIds,
|
||||
accessibleSkillIds: scopeSkillIds(
|
||||
accessibleSkillIds,
|
||||
ephemeralSkillsToggle ? undefined : agent.skills,
|
||||
),
|
||||
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
|
||||
},
|
||||
dbMethods,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EnvVar, createContentAggregator } = require('@librechat/agents');
|
||||
const {
|
||||
scopeSkillIds,
|
||||
initializeAgent,
|
||||
primeInvokedSkills,
|
||||
validateAgentModel,
|
||||
|
|
@ -107,11 +108,12 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId });
|
||||
|
||||
/** Query accessible skill IDs once per run (shared across all agents).
|
||||
* Requires both admin capability AND per-conversation toggle (if ephemeral). */
|
||||
* Skills activate when the admin capability is enabled AND either:
|
||||
* - the per-conversation toggle is on (ephemeral), OR
|
||||
* - the agent has stored skills (scoped by scopeSkillIds later). */
|
||||
const enabledCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities);
|
||||
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills;
|
||||
const skillsCapabilityEnabled =
|
||||
enabledCapabilities.has(AgentCapabilities.skills) && ephemeralSkillsToggle === true;
|
||||
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
|
||||
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
|
||||
|
||||
const accessibleSkillIds = skillsCapabilityEnabled
|
||||
? await findAccessibleResources({
|
||||
|
|
@ -236,7 +238,10 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
endpointOption,
|
||||
allowedProviders,
|
||||
isInitialAgent: true,
|
||||
accessibleSkillIds,
|
||||
accessibleSkillIds: scopeSkillIds(
|
||||
accessibleSkillIds,
|
||||
ephemeralSkillsToggle ? undefined : primaryAgent.skills,
|
||||
),
|
||||
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
|
||||
},
|
||||
{
|
||||
|
|
@ -283,6 +288,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
requestFiles,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
computeAccessibleSkillIds: (agent) =>
|
||||
scopeSkillIds(accessibleSkillIds, ephemeralSkillsToggle ? undefined : agent.skills),
|
||||
},
|
||||
{
|
||||
getAgent: db.getAgent,
|
||||
|
|
@ -399,6 +406,15 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
modelLabel: endpointOption.model_parameters.modelLabel,
|
||||
});
|
||||
|
||||
/** primeInvokedSkills reconstructs bodies of skills invoked in prior turns so
|
||||
* formatAgentMessages can rebuild HumanMessages and re-prime code-env files.
|
||||
* Unlike catalog injection and runtime invocation (both scoped per-agent),
|
||||
* history priming must use the user's full ACL-accessible set: historical
|
||||
* skill calls can reference skills no longer in any active agent's scope
|
||||
* (agent.skills edited, ephemeral toggle flipped), and scoping those out
|
||||
* would drop prior skill context and break file references in follow-up
|
||||
* turns. The ACL check remains the security gate; handleSkillToolCall is
|
||||
* where per-agent scoping prevents NEW invocations. */
|
||||
const handlePrimeInvokedSkills = skillsCapabilityEnabled
|
||||
? (payload) =>
|
||||
primeInvokedSkills({
|
||||
|
|
|
|||
|
|
@ -74,17 +74,25 @@ export default function AgentConfig() {
|
|||
const skills = useWatch({ control, name: 'skills' });
|
||||
const agent_id = useWatch({ control, name: 'id' });
|
||||
|
||||
const {
|
||||
codeEnabled,
|
||||
toolsEnabled,
|
||||
contextEnabled,
|
||||
actionsEnabled,
|
||||
skillsEnabled,
|
||||
artifactsEnabled,
|
||||
webSearchEnabled,
|
||||
fileSearchEnabled,
|
||||
} = useAgentCapabilities(agentsConfig?.capabilities);
|
||||
|
||||
const hasSkillsAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.SKILLS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: false });
|
||||
const showSkills = hasSkillsAccess && skillsEnabled;
|
||||
const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: showSkills });
|
||||
const skillsMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
// Backend list response: `{ skills: TSkillSummary[]; ... }` (renamed
|
||||
// from `.data` in the CRUD PR). This integration is gated behind
|
||||
// `false &&` below so this map is currently unreachable — kept here
|
||||
// so the section compiles for when agent-skills wiring lands.
|
||||
for (const skill of skillsData?.skills ?? []) {
|
||||
map.set(skill._id, skill.name);
|
||||
}
|
||||
|
|
@ -103,16 +111,6 @@ export default function AgentConfig() {
|
|||
return newFileMap;
|
||||
}, [fileMap, agentFiles]);
|
||||
|
||||
const {
|
||||
codeEnabled,
|
||||
toolsEnabled,
|
||||
contextEnabled,
|
||||
actionsEnabled,
|
||||
artifactsEnabled,
|
||||
webSearchEnabled,
|
||||
fileSearchEnabled,
|
||||
} = useAgentCapabilities(agentsConfig?.capabilities);
|
||||
|
||||
const context_files = useMemo(() => {
|
||||
if (typeof agent === 'string') {
|
||||
return [];
|
||||
|
|
@ -340,8 +338,7 @@ export default function AgentConfig() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* WIP: Skills — remove `false &&` to re-enable */}
|
||||
{false && hasSkillsAccess && (
|
||||
{showSkills && (
|
||||
<div className="mb-4">
|
||||
<label className="text-token-text-primary mb-2 block text-sm font-medium">
|
||||
{localize('com_ui_skills')}
|
||||
|
|
@ -366,6 +363,7 @@ export default function AgentConfig() {
|
|||
methods.setValue(
|
||||
'skills',
|
||||
current.filter((id) => id !== skillId),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
className="ml-2 flex-shrink-0 text-text-secondary transition-colors hover:text-text-primary"
|
||||
|
|
@ -581,10 +579,7 @@ export default function AgentConfig() {
|
|||
endpoint={EModelEndpoint.agents}
|
||||
/>
|
||||
)}
|
||||
{/* WIP: Skills — remove `false &&` to re-enable */}
|
||||
{false && hasSkillsAccess && (
|
||||
<SkillSelectDialog isOpen={showSkillDialog} setIsOpen={setShowSkillDialog} />
|
||||
)}
|
||||
{showSkills && <SkillSelectDialog isOpen={showSkillDialog} setIsOpen={setShowSkillDialog} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
|
|||
category,
|
||||
support_contact,
|
||||
tool_options,
|
||||
skills,
|
||||
avatar_action: avatarActionState,
|
||||
} = data;
|
||||
|
||||
|
|
@ -101,6 +102,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n
|
|||
category,
|
||||
support_contact,
|
||||
tool_options,
|
||||
skills,
|
||||
...(shouldResetAvatar ? { avatar: null } : {}),
|
||||
},
|
||||
provider,
|
||||
|
|
|
|||
|
|
@ -106,6 +106,15 @@ function AgentSelect({
|
|||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'skills' &&
|
||||
Array.isArray(value) &&
|
||||
value.every((item) => typeof item === 'string')
|
||||
) {
|
||||
formValues[name] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'edges' && Array.isArray(value)) {
|
||||
formValues[name] = value;
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ jest.mock('@librechat/agents', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
import { Types } from 'mongoose';
|
||||
import { scopeSkillIds } from '../skills';
|
||||
import { extractInvokedSkillsFromPayload } from '../run';
|
||||
|
||||
describe('extractInvokedSkillsFromPayload', () => {
|
||||
|
|
@ -181,3 +183,68 @@ describe('extractInvokedSkillsFromPayload', () => {
|
|||
expect(result.has('pdf')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeSkillIds', () => {
|
||||
const makeId = () => new Types.ObjectId();
|
||||
|
||||
it('returns the full set when agentSkills is undefined (not configured)', () => {
|
||||
const a = makeId();
|
||||
const b = makeId();
|
||||
const accessible = [a, b];
|
||||
expect(scopeSkillIds(accessible, undefined)).toBe(accessible);
|
||||
});
|
||||
|
||||
it('returns the full set when agentSkills is null (not configured)', () => {
|
||||
const a = makeId();
|
||||
const b = makeId();
|
||||
const accessible = [a, b];
|
||||
expect(scopeSkillIds(accessible, null)).toBe(accessible);
|
||||
});
|
||||
|
||||
it('returns [] when agentSkills is an empty array (explicit none)', () => {
|
||||
const accessible = [makeId(), makeId()];
|
||||
expect(scopeSkillIds(accessible, [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns intersection when agentSkills overlaps accessibleSkillIds', () => {
|
||||
const a = makeId();
|
||||
const b = makeId();
|
||||
const c = makeId();
|
||||
const accessible = [a, b, c];
|
||||
const scoped = scopeSkillIds(accessible, [a.toString(), c.toString()]);
|
||||
expect(scoped).toHaveLength(2);
|
||||
expect(scoped.map((o) => o.toString())).toEqual([a.toString(), c.toString()]);
|
||||
});
|
||||
|
||||
it('returns [] when agentSkills is disjoint from accessibleSkillIds', () => {
|
||||
const a = makeId();
|
||||
const b = makeId();
|
||||
const accessible = [a, b];
|
||||
const unrelated = makeId().toString();
|
||||
expect(scopeSkillIds(accessible, [unrelated])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the full accessible set when agentSkills exactly matches it', () => {
|
||||
const a = makeId();
|
||||
const b = makeId();
|
||||
const accessible = [a, b];
|
||||
const scoped = scopeSkillIds(accessible, [a.toString(), b.toString()]);
|
||||
expect(scoped).toHaveLength(2);
|
||||
expect(scoped.map((o) => o.toString()).sort()).toEqual([a.toString(), b.toString()].sort());
|
||||
});
|
||||
|
||||
it('filters out agentSkills entries that the user does not have ACL access to', () => {
|
||||
const a = makeId();
|
||||
const accessible = [a];
|
||||
const notAccessible = makeId().toString();
|
||||
const scoped = scopeSkillIds(accessible, [a.toString(), notAccessible]);
|
||||
expect(scoped).toHaveLength(1);
|
||||
expect(scoped[0].toString()).toBe(a.toString());
|
||||
});
|
||||
|
||||
it('returns [] when accessibleSkillIds is empty regardless of agentSkills', () => {
|
||||
expect(scopeSkillIds([], undefined)).toEqual([]);
|
||||
expect(scopeSkillIds([], [])).toEqual([]);
|
||||
expect(scopeSkillIds([], [new Types.ObjectId().toString()])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ export interface DiscoverConnectedAgentsParams {
|
|||
* don't bypass the same sharing boundary enforced at the route.
|
||||
*/
|
||||
resourceType?: string;
|
||||
/**
|
||||
* Optional per-sub-agent skill scoper. When provided, its return value
|
||||
* is forwarded to `initializeAgent` as `accessibleSkillIds` so each
|
||||
* handoff agent sees only the skills that match its own `skills`
|
||||
* allowlist (or the full accessible set when scoping is disabled).
|
||||
*/
|
||||
computeAccessibleSkillIds?: (agent: Agent) => InitializeAgentParams['accessibleSkillIds'];
|
||||
}
|
||||
|
||||
export interface DiscoverConnectedAgentsDeps {
|
||||
|
|
@ -126,6 +133,7 @@ export async function discoverConnectedAgents(
|
|||
conversationId,
|
||||
parentMessageId,
|
||||
resourceType = ResourceType.AGENT,
|
||||
computeAccessibleSkillIds,
|
||||
} = params;
|
||||
|
||||
const {
|
||||
|
|
@ -223,6 +231,7 @@ export async function discoverConnectedAgents(
|
|||
parentMessageId,
|
||||
endpointOption: subAgentEndpointOption,
|
||||
allowedProviders,
|
||||
accessibleSkillIds: computeAccessibleSkillIds?.(agent),
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,31 @@ import type { InitializeAgentDbMethods } from './initialize';
|
|||
|
||||
const SKILL_CATALOG_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Scopes user-accessible skill IDs to only those configured on the agent.
|
||||
*
|
||||
* Semantics (pinned by unit tests):
|
||||
* - `undefined` / `null` → not configured, returns the full accessible catalog.
|
||||
* - `[]` (empty array) → explicitly none, returns `[]`. A user who narrows their
|
||||
* agent to a subset and then removes all entries is explicitly opting out of
|
||||
* the full catalog fallback.
|
||||
* - non-empty array of skill `_id` hex strings → intersection of accessible IDs
|
||||
* and agent-configured IDs.
|
||||
*/
|
||||
export function scopeSkillIds(
|
||||
accessibleSkillIds: Types.ObjectId[],
|
||||
agentSkills: string[] | null | undefined,
|
||||
): Types.ObjectId[] {
|
||||
if (agentSkills == null) {
|
||||
return accessibleSkillIds;
|
||||
}
|
||||
if (agentSkills.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const agentSet = new Set(agentSkills);
|
||||
return accessibleSkillIds.filter((oid) => agentSet.has(oid.toString()));
|
||||
}
|
||||
|
||||
export interface InjectSkillCatalogParams {
|
||||
agent: Agent;
|
||||
toolDefinitions: LCTool[] | undefined;
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export const agentBaseSchema = z.object({
|
|||
avatar: agentAvatarSchema.nullable().optional(),
|
||||
model_parameters: z.record(z.unknown()).optional(),
|
||||
tools: z.array(z.string()).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids: z.array(z.string()).optional(),
|
||||
edges: z.array(graphEdgeSchema).optional(),
|
||||
|
|
|
|||
|
|
@ -293,7 +293,11 @@ export const defaultAgentFormValues = {
|
|||
name: '',
|
||||
email: '',
|
||||
},
|
||||
skills: [],
|
||||
/** `undefined` = not configured, full catalog applies. `[]` = explicitly none.
|
||||
* Keeping this `undefined` ensures a brand-new agent (where the user never
|
||||
* interacted with the skills UI) does not accidentally persist "explicit none"
|
||||
* on first save — removeNullishValues strips the field server-side. */
|
||||
skills: undefined as string[] | undefined,
|
||||
};
|
||||
|
||||
export const ImageVisionTool: FunctionTool = {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ const agentSchema = new Schema<IAgent>(
|
|||
type: [String],
|
||||
default: undefined,
|
||||
},
|
||||
skills: {
|
||||
type: [String],
|
||||
default: undefined,
|
||||
},
|
||||
tool_kwargs: {
|
||||
type: [{ type: Schema.Types.Mixed }],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface IAgent extends Omit<Document, 'model'> {
|
|||
access_level?: number;
|
||||
recursion_limit?: number;
|
||||
tools?: string[];
|
||||
skills?: string[];
|
||||
tool_kwargs?: Array<unknown>;
|
||||
actions?: string[];
|
||||
author: Types.ObjectId;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue