From 84e77fe635be0546b4961c8405cefbda8c755b50 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 5 Feb 2026 17:40:47 -0800 Subject: [PATCH 1/9] feat(skills): added skills to agent block --- apps/sim/app/api/skills/route.ts | 192 + .../components/sub-block/components/index.ts | 1 + .../components/skill-input/skill-input.tsx | 180 + .../editor/components/sub-block/sub-block.tsx | 12 + .../settings-modal/components/index.ts | 1 + .../skills/components/skill-modal.tsx | 179 + .../components/skills/skills.tsx | 215 + .../settings-modal/settings-modal.tsx | 5 + apps/sim/blocks/blocks.test.ts | 4 +- apps/sim/blocks/blocks/agent.ts | 7 + apps/sim/blocks/types.ts | 1 + .../access-control/utils/permission-check.ts | 27 + .../executor/handlers/agent/agent-handler.ts | 38 +- .../handlers/agent/skills-resolver.ts | 107 + apps/sim/executor/handlers/agent/types.ts | 7 + apps/sim/hooks/queries/skills.ts | 292 + apps/sim/lib/permission-groups/types.ts | 3 + apps/sim/lib/workflows/skills/operations.ts | 86 + apps/sim/tools/index.ts | 26 + .../db/migrations/0152_parallel_frog_thor.sql | 15 + .../db/migrations/meta/0152_snapshot.json | 10619 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 21 + 23 files changed, 12041 insertions(+), 4 deletions(-) create mode 100644 apps/sim/app/api/skills/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/skill-input/skill-input.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/skills.tsx create mode 100644 apps/sim/executor/handlers/agent/skills-resolver.ts create mode 100644 apps/sim/hooks/queries/skills.ts create mode 100644 apps/sim/lib/workflows/skills/operations.ts create mode 100644 packages/db/migrations/0152_parallel_frog_thor.sql create mode 100644 packages/db/migrations/meta/0152_snapshot.json diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts new file mode 100644 index 0000000000..005420c278 --- /dev/null +++ b/apps/sim/app/api/skills/route.ts @@ -0,0 +1,192 @@ +import { db } from '@sim/db' +import { skill } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { desc, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { upsertSkills } from '@/lib/workflows/skills/operations' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('SkillsAPI') + +const SkillSchema = z.object({ + skills: z.array( + z.object({ + id: z.string().optional(), + name: z + .string() + .min(1, 'Skill name is required') + .max(64) + .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)'), + description: z.string().min(1, 'Description is required').max(1024), + content: z.string().min(1, 'Content is required'), + }) + ), + workspaceId: z.string().optional(), +}) + +/** GET - Fetch all skills for a workspace */ +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + const searchParams = request.nextUrl.searchParams + const workspaceId = searchParams.get('workspaceId') + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized skills access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = authResult.userId + + if (!workspaceId) { + logger.warn(`[${requestId}] Missing workspaceId`) + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!userPermission) { + logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const result = await db + .select() + .from(skill) + .where(eq(skill.workspaceId, workspaceId)) + .orderBy(desc(skill.createdAt)) + + return NextResponse.json({ data: result }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching skills:`, error) + return NextResponse.json({ error: 'Failed to fetch skills' }, { status: 500 }) + } +} + +/** POST - Create or update skills */ +export async function POST(req: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized skills update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = authResult.userId + const body = await req.json() + + try { + const { skills, workspaceId } = SkillSchema.parse(body) + + if (!workspaceId) { + logger.warn(`[${requestId}] Missing workspaceId in request body`) + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!userPermission) { + logger.warn( + `[${requestId}] User ${userId} does not have access to workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + if (userPermission !== 'admin' && userPermission !== 'write') { + logger.warn( + `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) + } + + const resultSkills = await upsertSkills({ + skills, + workspaceId, + userId, + requestId, + }) + + return NextResponse.json({ success: true, data: resultSkills }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid skills data`, { + errors: validationError.errors, + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError + } + } catch (error) { + logger.error(`[${requestId}] Error updating skills`, error) + const errorMessage = error instanceof Error ? error.message : 'Failed to update skills' + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +} + +/** DELETE - Delete a skill by ID */ +export async function DELETE(request: NextRequest) { + const requestId = generateRequestId() + const searchParams = request.nextUrl.searchParams + const skillId = searchParams.get('id') + const workspaceId = searchParams.get('workspaceId') + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized skill deletion attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = authResult.userId + + if (!skillId) { + logger.warn(`[${requestId}] Missing skill ID for deletion`) + return NextResponse.json({ error: 'Skill ID is required' }, { status: 400 }) + } + + if (!workspaceId) { + logger.warn(`[${requestId}] Missing workspaceId for deletion`) + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!userPermission) { + logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + if (userPermission !== 'admin' && userPermission !== 'write') { + logger.warn( + `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) + } + + const existingSkill = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1) + + if (existingSkill.length === 0) { + logger.warn(`[${requestId}] Skill not found: ${skillId}`) + return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) + } + + if (existingSkill[0].workspaceId !== workspaceId) { + logger.warn(`[${requestId}] Skill ${skillId} does not belong to workspace ${workspaceId}`) + return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) + } + + await db.delete(skill).where(eq(skill.id, skillId)) + + logger.info(`[${requestId}] Deleted skill: ${skillId}`) + return NextResponse.json({ success: true }) + } catch (error) { + logger.error(`[${requestId}] Error deleting skill:`, error) + return NextResponse.json({ error: 'Failed to delete skill' }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index 0cfc45369b..a66b4ac04d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -24,6 +24,7 @@ export { ResponseFormat } from './response/response-format' export { ScheduleInfo } from './schedule-info/schedule-info' export { SheetSelectorInput } from './sheet-selector/sheet-selector-input' export { ShortInput } from './short-input/short-input' +export { SkillInput } from './skill-input/skill-input' export { SlackSelectorInput } from './slack-selector/slack-selector-input' export { SliderInput } from './slider-input/slider-input' export { InputFormat } from './starter/input-format' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/skill-input/skill-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/skill-input/skill-input.tsx new file mode 100644 index 0000000000..834413aded --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/skill-input/skill-input.tsx @@ -0,0 +1,180 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { Plus, Sparkles, XIcon } from 'lucide-react' +import { useParams } from 'next/navigation' +import { Combobox, type ComboboxOptionGroup } from '@/components/emcn' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import { SkillModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal' +import type { SkillDefinition } from '@/hooks/queries/skills' +import { useSkills } from '@/hooks/queries/skills' +import { usePermissionConfig } from '@/hooks/use-permission-config' + +interface StoredSkill { + skillId: string + name?: string +} + +interface SkillInputProps { + blockId: string + subBlockId: string + isPreview?: boolean + previewValue?: unknown + disabled?: boolean +} + +export function SkillInput({ + blockId, + subBlockId, + isPreview, + previewValue, + disabled, +}: SkillInputProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + + const { config: permissionConfig } = usePermissionConfig() + const { data: workspaceSkills = [] } = useSkills(workspaceId) + const [value, setValue] = useSubBlockValue(blockId, subBlockId) + const [showCreateModal, setShowCreateModal] = useState(false) + const [editingSkill, setEditingSkill] = useState(null) + const [open, setOpen] = useState(false) + + const selectedSkills: StoredSkill[] = useMemo(() => { + if (isPreview && previewValue) { + return Array.isArray(previewValue) ? previewValue : [] + } + return Array.isArray(value) ? value : [] + }, [isPreview, previewValue, value]) + + const selectedIds = useMemo(() => new Set(selectedSkills.map((s) => s.skillId)), [selectedSkills]) + + const skillsDisabled = permissionConfig.disableSkills + + const skillGroups = useMemo((): ComboboxOptionGroup[] => { + const groups: ComboboxOptionGroup[] = [] + + if (!skillsDisabled) { + groups.push({ + items: [ + { + label: 'Create Skill', + value: 'action-create-skill', + icon: Plus, + onSelect: () => { + setShowCreateModal(true) + setOpen(false) + }, + disabled: isPreview, + }, + ], + }) + } + + const availableSkills = workspaceSkills.filter((s) => !selectedIds.has(s.id)) + if (availableSkills.length > 0) { + groups.push({ + section: 'Skills', + items: availableSkills.map((s) => { + return { + label: s.name, + value: `skill-${s.id}`, + icon: Sparkles, + onSelect: () => { + const newSkills: StoredSkill[] = [...selectedSkills, { skillId: s.id, name: s.name }] + setValue(newSkills) + setOpen(false) + }, + } + }), + }) + } + + return groups + }, [workspaceSkills, selectedIds, selectedSkills, setValue, isPreview, skillsDisabled]) + + const handleRemove = useCallback( + (skillId: string) => { + const newSkills = selectedSkills.filter((s) => s.skillId !== skillId) + setValue(newSkills) + }, + [selectedSkills, setValue] + ) + + const handleSkillSaved = useCallback(() => { + setShowCreateModal(false) + setEditingSkill(null) + }, []) + + const resolveSkillName = useCallback( + (stored: StoredSkill): string => { + const found = workspaceSkills.find((s) => s.id === stored.skillId) + return found?.name ?? stored.name ?? stored.skillId + }, + [workspaceSkills] + ) + + return ( + <> +
+ + + {selectedSkills.length > 0 && ( +
+ {selectedSkills.map((stored) => { + const fullSkill = workspaceSkills.find((s) => s.id === stored.skillId) + return ( +
{ + if (fullSkill && !disabled && !isPreview) { + setEditingSkill(fullSkill) + } + }} + > + + {resolveSkillName(stored)} + {!disabled && !isPreview && ( + + )} +
+ ) + })} +
+ )} +
+ + { + if (!isOpen) { + setShowCreateModal(false) + setEditingSkill(null) + } + }} + onSave={handleSkillSaved} + initialValues={editingSkill ?? undefined} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index cd1e9168e8..800ed5f932 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -32,6 +32,7 @@ import { ScheduleInfo, SheetSelectorInput, ShortInput, + SkillInput, SlackSelectorInput, SliderInput, Switch, @@ -687,6 +688,17 @@ function SubBlockComponent({ /> ) + case 'skill-input': + return ( + + ) + case 'checkbox-list': return ( void + onSave: () => void + initialValues?: SkillDefinition +} + +const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/ + +export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillModalProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + + const createSkill = useCreateSkill() + const updateSkill = useUpdateSkill() + + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [content, setContent] = useState('') + const [formError, setFormError] = useState('') + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (open) { + if (initialValues) { + setName(initialValues.name) + setDescription(initialValues.description) + setContent(initialValues.content) + } else { + setName('') + setDescription('') + setContent('') + } + setFormError('') + } + }, [open, initialValues]) + + const hasChanges = useMemo(() => { + if (!initialValues) return true + return ( + name !== initialValues.name || + description !== initialValues.description || + content !== initialValues.content + ) + }, [name, description, content, initialValues]) + + const handleSave = async () => { + if (!name.trim()) { + setFormError('Name is required') + return + } + if (name.length > 64) { + setFormError('Name must be 64 characters or less') + return + } + if (!KEBAB_CASE_REGEX.test(name)) { + setFormError('Name must be kebab-case (e.g. my-skill)') + return + } + if (!description.trim()) { + setFormError('Description is required') + return + } + if (!content.trim()) { + setFormError('Content is required') + return + } + + setSaving(true) + + try { + if (initialValues) { + await updateSkill.mutateAsync({ + workspaceId, + skillId: initialValues.id, + updates: { name, description, content }, + }) + } else { + await createSkill.mutateAsync({ + workspaceId, + skill: { name, description, content }, + }) + } + onSave() + } catch { + // Error is handled by React Query + } finally { + setSaving(false) + } + } + + return ( + + + {initialValues ? 'Edit Skill' : 'Create Skill'} + +
+
+ + { + setName(e.target.value) + if (formError) setFormError('') + }} + /> + {formError && ( + {formError} + )} +
+ +
+ + { + setDescription(e.target.value) + if (formError) setFormError('') + }} + maxLength={1024} + /> +
+ +
+ +