From 06bdb5ad6a9f5668f80032a9b46552845ad1fcb9 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:10:14 +0100 Subject: [PATCH] `[SDK]` Agent in manifest (#18431) # Introduction Adding agent in the manifest, required for twenty standard app extraction out of twenty-server --- .../src/create-app.command.ts | 2 + .../src/types/scaffolding-options.ts | 1 + .../src/utils/__tests__/app-template.spec.ts | 4 + .../src/utils/app-template.ts | 38 +++ .../developers/extend/capabilities/apps.mdx | 46 +++- .../app-dev/expected-manifest.ts | 1 + .../app-dev/expected-manifest.ts | 1 + .../src/cli/commands/entity/entity-add.ts | 11 + .../__tests__/manifest-validate.spec.ts | 1 + .../build/manifest/manifest-build.ts | 15 ++ .../build/manifest/manifest-extract-config.ts | 3 + .../cli/utilities/dev/ui/dev-ui-constants.ts | 1 + .../utilities/entity/entity-agent-template.ts | 26 ++ .../twenty-sdk/src/sdk/agents/define-agent.ts | 25 ++ packages/twenty-sdk/src/sdk/index.ts | 1 + ...est-all-universal-flat-entity-maps.util.ts | 14 +- ...cation-manifest-metadata-names.constant.ts | 1 + ...t-manifest-to-universal-flat-agent.util.ts | 35 +++ ...-manifest-update-agent.integration-spec.ts | 240 ++++++++++++++++++ .../utils/build-base-manifest.util.ts | 1 + .../src/application/agentManifestType.ts | 10 + .../enums/syncable-entities.enum.ts | 1 + .../twenty-shared/src/application/index.ts | 1 + .../src/application/manifestType.ts | 2 + 24 files changed, 476 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/utilities/entity/entity-agent-template.ts create mode 100644 packages/twenty-sdk/src/sdk/agents/define-agent.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/utils/from-agent-manifest-to-universal-flat-agent.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-agent.integration-spec.ts create mode 100644 packages/twenty-shared/src/application/agentManifestType.ts diff --git a/packages/create-twenty-app/src/create-app.command.ts b/packages/create-twenty-app/src/create-app.command.ts index 1e3ae4f7d8..0ca692e1fc 100644 --- a/packages/create-twenty-app/src/create-app.command.ts +++ b/packages/create-twenty-app/src/create-app.command.ts @@ -113,6 +113,7 @@ export class CreateAppCommand { includeExampleView: false, includeExampleNavigationMenuItem: false, includeExampleSkill: false, + includeExampleAgent: false, includeExampleIntegrationTest: false, }; } @@ -126,6 +127,7 @@ export class CreateAppCommand { includeExampleNavigationMenuItem: true, includeExampleSkill: true, includeExampleIntegrationTest: true, + includeExampleAgent: true, }; } diff --git a/packages/create-twenty-app/src/types/scaffolding-options.ts b/packages/create-twenty-app/src/types/scaffolding-options.ts index 09c121512e..02a239d0fc 100644 --- a/packages/create-twenty-app/src/types/scaffolding-options.ts +++ b/packages/create-twenty-app/src/types/scaffolding-options.ts @@ -8,5 +8,6 @@ export type ExampleOptions = { includeExampleView: boolean; includeExampleNavigationMenuItem: boolean; includeExampleSkill: boolean; + includeExampleAgent: boolean; includeExampleIntegrationTest: boolean; }; diff --git a/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts b/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts index ff63b45a0f..fcd54c39f7 100644 --- a/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts +++ b/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts @@ -25,6 +25,7 @@ const ALL_EXAMPLES: ExampleOptions = { includeExampleView: true, includeExampleNavigationMenuItem: true, includeExampleSkill: true, + includeExampleAgent: true, includeExampleIntegrationTest: true, }; @@ -41,6 +42,7 @@ const NO_EXAMPLES: ExampleOptions = { includeExampleObject: false, includeExampleField: false, includeExampleSkill: false, + includeExampleAgent: false, includeExampleLogicFunction: false, includeExampleFrontComponent: false, includeExampleView: false, @@ -476,6 +478,7 @@ describe('copyBaseApplicationProject', () => { includeExampleObject: false, includeExampleField: false, includeExampleSkill: false, + includeExampleAgent: false, includeExampleLogicFunction: false, includeExampleFrontComponent: true, includeExampleView: false, @@ -513,6 +516,7 @@ describe('copyBaseApplicationProject', () => { exampleOptions: { includeExampleObject: false, includeExampleSkill: false, + includeExampleAgent: false, includeExampleField: false, includeExampleLogicFunction: true, includeExampleFrontComponent: false, diff --git a/packages/create-twenty-app/src/utils/app-template.ts b/packages/create-twenty-app/src/utils/app-template.ts index ff42ae6ab0..f4cccffbe3 100644 --- a/packages/create-twenty-app/src/utils/app-template.ts +++ b/packages/create-twenty-app/src/utils/app-template.ts @@ -103,6 +103,14 @@ export const copyBaseApplicationProject = async ({ }); } + if (exampleOptions.includeExampleAgent) { + await createExampleAgent({ + appDirectory: sourceFolderPath, + fileFolder: 'agents', + fileName: 'example-agent.ts', + }); + } + if (exampleOptions.includeExampleIntegrationTest) { await scaffoldIntegrationTest({ appDirectory, @@ -519,6 +527,36 @@ export default defineSkill({ await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content); }; +const createExampleAgent = async ({ + appDirectory, + fileFolder, + fileName, +}: { + appDirectory: string; + fileFolder?: string; + fileName: string; +}) => { + const universalIdentifier = v4(); + + const content = `import { defineAgent } from 'twenty-sdk'; + +export const EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER = + '${universalIdentifier}'; + +export default defineAgent({ + universalIdentifier: EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER, + name: 'example-agent', + label: 'Example Agent', + description: 'A sample AI agent for your application', + icon: 'IconRobot', + prompt: 'You are a helpful assistant. Help users with their questions and tasks.', +}); +`; + + await fs.ensureDir(join(appDirectory, fileFolder ?? '')); + await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content); +}; + const createApplicationConfig = async ({ displayName, description, diff --git a/packages/twenty-docs/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/developers/extend/capabilities/apps.mdx index 4ae6748e99..ef2cb4535d 100644 --- a/packages/twenty-docs/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/developers/extend/capabilities/apps.mdx @@ -14,7 +14,7 @@ Apps let you build and manage Twenty customizations **as code**. Instead of conf **What you can do today:** - Define custom objects and fields as code (managed data model) - Build logic functions with custom triggers -- Define skills for AI agents +- Define skills and agents for AI - Deploy the same app across multiple workspaces ## Prerequisites @@ -38,7 +38,7 @@ yarn twenty app:dev The scaffolder supports two modes for controlling which example files are included: ```bash filename="Terminal" -# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill) +# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill, agent) npx create-twenty-app@latest my-app # Minimal: only core files (application-config.ts and default-role.ts) @@ -114,8 +114,10 @@ my-twenty-app/ │ └── example-view.ts # Example saved view definition ├── navigation-menu-items/ │ └── example-navigation-menu-item.ts # Example sidebar navigation link - └── skills/ - └── example-skill.ts # Example AI agent skill definition + ├── skills/ + │ └── example-skill.ts # Example AI agent skill definition + └── agents/ + └── example-agent.ts # Example AI agent definition ``` With `--minimal`, only the core files are created (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts`, and `logic-functions/post-install.ts`). @@ -147,6 +149,7 @@ The SDK detects entities by parsing your TypeScript files for **`export default | `defineView()` | Saved view definitions | | `defineNavigationMenuItem()` | Navigation menu item definitions | | `defineSkill()` | AI agent skill definitions | +| `defineAgent()` | AI agent definitions | **File naming is flexible.** Entity detection is AST-based — the SDK scans your source files for the `export default define({...})` pattern. You can organize your files and folders however you like. Grouping by entity type (e.g., `logic-functions/`, `roles/`) is just a convention for code organization, not a requirement. @@ -223,6 +226,7 @@ The SDK provides helper functions for defining your app entities. As described i | `defineView()` | Define saved views for objects | | `defineNavigationMenuItem()` | Define sidebar navigation links | | `defineSkill()` | Define AI agent skills | +| `defineAgent()` | Define AI agents with system prompts | These functions validate your configuration at build time and provide IDE autocompletion and type safety. @@ -790,6 +794,40 @@ You can create new skills in two ways: - **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill. - **Manual**: Create a new file and use `defineSkill()`, following the same pattern. +### Agents + +Agents define AI agents with system prompts that can operate within your workspace. Use `defineAgent()` to define agents with built-in validation: + +```typescript +// src/agents/example-agent.ts +import { defineAgent } from 'twenty-sdk'; + +export default defineAgent({ + universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + name: 'sales-assistant', + label: 'Sales Assistant', + description: 'An AI agent that helps with sales tasks', + icon: 'IconRobot', + prompt: `You are a sales assistant. Help users with: +1. Researching prospects and companies +2. Drafting personalized outreach messages +3. Tracking follow-ups and next steps +4. Analyzing deal pipeline and suggesting actions`, +}); +``` + +Key points: +- `name` is a unique identifier string for the agent (kebab-case recommended). +- `label` is the human-readable display name shown in the UI. +- `prompt` contains the system prompt — this is the instruction text that defines the agent's behavior. +- `icon` (optional) sets the icon displayed in the UI. +- `description` (optional) provides additional context about the agent's purpose. + +You can create new agents in two ways: + +- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new agent. +- **Manual**: Create a new file and use `defineAgent()`, following the same pattern. + ### Generated typed clients Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema: diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts index fb3db6466a..7eea599d65 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts @@ -19,6 +19,7 @@ export const EXPECTED_MANIFEST: Manifest = { }, ], skills: [], + agents: [], application: { applicationVariables: { DEFAULT_RECIPIENT_NAME: { diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts index 8734575fc2..a6d3f6b00a 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts @@ -18,6 +18,7 @@ export const EXPECTED_MANIFEST: Manifest = { apiClientChecksum: null, }, skills: [], + agents: [], publicAssets: [], fields: [ { diff --git a/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts b/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts index 90e27246a6..aa3a5e7397 100644 --- a/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts +++ b/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts @@ -16,6 +16,7 @@ import { getNavigationMenuItemBaseFile } from '@/cli/utilities/entity/entity-nav import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template'; import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template'; import { getRoleBaseFile } from '@/cli/utilities/entity/entity-role-template'; +import { getAgentBaseFile } from '@/cli/utilities/entity/entity-agent-template'; import { getSkillBaseFile } from '@/cli/utilities/entity/entity-skill-template'; import { getViewBaseFile } from '@/cli/utilities/entity/entity-view-template'; import { ensureDir, pathExists } from '@/cli/utilities/file/fs-utils'; @@ -144,6 +145,16 @@ export class EntityAddCommand { return { name, file }; } + case SyncableEntity.Agent: { + const name = await this.getEntityName(entity); + + const file = getAgentBaseFile({ + name, + }); + + return { name, file }; + } + case SyncableEntity.View: { const entityData = await this.getViewData(); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts index d4aa3e99ad..7a8b132d68 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts @@ -33,6 +33,7 @@ const validManifest: Manifest = { logicFunctions: [], roles: [], skills: [], + agents: [], publicAssets: [], views: [], navigationMenuItems: [], diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index 33cd3adbcb..7eb3527422 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -19,6 +19,7 @@ import { readFile } from 'node:fs/promises'; import { basename, extname, relative } from 'path'; import { glob } from 'tinyglobby'; import { + type AgentManifest, type ApplicationManifest, type AssetManifest, ASSETS_DIR, @@ -68,6 +69,7 @@ export const buildManifest = async ( const fields: FieldManifest[] = []; const roles: RoleManifest[] = []; const skills: SkillManifest[] = []; + const agents: AgentManifest[] = []; const logicFunctions: LogicFunctionManifest[] = []; const frontComponents: FrontComponentManifest[] = []; const publicAssets: AssetManifest[] = []; @@ -82,6 +84,7 @@ export const buildManifest = async ( const fieldsFilePaths: string[] = []; const rolesFilePaths: string[] = []; const skillsFilePaths: string[] = []; + const agentsFilePaths: string[] = []; const logicFunctionsFilePaths: string[] = []; const frontComponentsFilePaths: string[] = []; const publicAssetsFilePaths: string[] = []; @@ -184,6 +187,16 @@ export const buildManifest = async ( skillsFilePaths.push(relativePath); break; } + case ManifestEntityKey.Agents: { + const extract = await extractManifestFromFile({ + appPath, + filePath, + }); + agents.push(extract.config); + errors.push(...extract.errors); + agentsFilePaths.push(relativePath); + break; + } case ManifestEntityKey.LogicFunctions: { const extract = await extractManifestFromFile({ appPath, @@ -363,6 +376,7 @@ export const buildManifest = async ( fields, roles, skills, + agents, logicFunctions, frontComponents, publicAssets, @@ -377,6 +391,7 @@ export const buildManifest = async ( fields: fieldsFilePaths, roles: rolesFilePaths, skills: skillsFilePaths, + agents: agentsFilePaths, logicFunctions: logicFunctionsFilePaths, frontComponents: frontComponentsFilePaths, publicAssets: publicAssetsFilePaths, diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts index 29be4e4c22..2c0cb4ccdc 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts @@ -9,6 +9,7 @@ export enum TargetFunction { DefineObject = 'defineObject', DefineRole = 'defineRole', DefineSkill = 'defineSkill', + DefineAgent = 'defineAgent', DefineFrontComponent = 'defineFrontComponent', DefineView = 'defineView', DefineNavigationMenuItem = 'defineNavigationMenuItem', @@ -22,6 +23,7 @@ export enum ManifestEntityKey { Objects = 'objects', Roles = 'roles', Skills = 'skills', + Agents = 'agents', FrontComponents = 'frontComponents', PublicAssets = 'publicAssets', Views = 'views', @@ -45,6 +47,7 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record< [TargetFunction.DefineObject]: ManifestEntityKey.Objects, [TargetFunction.DefineRole]: ManifestEntityKey.Roles, [TargetFunction.DefineSkill]: ManifestEntityKey.Skills, + [TargetFunction.DefineAgent]: ManifestEntityKey.Agents, [TargetFunction.DefineFrontComponent]: ManifestEntityKey.FrontComponents, [TargetFunction.DefineView]: ManifestEntityKey.Views, [TargetFunction.DefineNavigationMenuItem]: diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts index 906703fbee..81db345070 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts @@ -102,6 +102,7 @@ export const ENTITY_LABELS: Record = { [SyncableEntity.View]: 'Views', [SyncableEntity.NavigationMenuItem]: 'Navigation menu items', [SyncableEntity.PageLayout]: 'Page layouts', + [SyncableEntity.Agent]: 'Agents', }; export const ENTITY_ORDER = Object.keys(ENTITY_LABELS) as SyncableEntity[]; diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-agent-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-agent-template.ts new file mode 100644 index 0000000000..573a16bb23 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-agent-template.ts @@ -0,0 +1,26 @@ +import { kebabCase } from '@/cli/utilities/string/kebab-case'; +import { v4 } from 'uuid'; + +export const getAgentBaseFile = ({ + name, + universalIdentifier = v4(), +}: { + name: string; + universalIdentifier?: string; +}) => { + const kebabCaseName = kebabCase(name); + + return `import { defineAgent } from 'twenty-sdk'; + +export const ${kebabCaseName.toUpperCase().replace(/-/g, '_')}_AGENT_UNIVERSAL_IDENTIFIER = + '${universalIdentifier}'; + +export default defineAgent({ + universalIdentifier: ${kebabCaseName.toUpperCase().replace(/-/g, '_')}_AGENT_UNIVERSAL_IDENTIFIER, + name: '${kebabCaseName}', + label: '${name}', + description: 'Add a description for your agent', + prompt: 'Add the agent system prompt here', +}); +`; +}; diff --git a/packages/twenty-sdk/src/sdk/agents/define-agent.ts b/packages/twenty-sdk/src/sdk/agents/define-agent.ts new file mode 100644 index 0000000000..f83ba70f5b --- /dev/null +++ b/packages/twenty-sdk/src/sdk/agents/define-agent.ts @@ -0,0 +1,25 @@ +import { type DefineEntity } from '@/sdk/common/types/define-entity.type'; +import { createValidationResult } from '@/sdk/common/utils/create-validation-result'; +import { type AgentManifest } from 'twenty-shared/application'; + +export const defineAgent: DefineEntity = (config) => { + const errors: string[] = []; + + if (!config.universalIdentifier) { + errors.push('Agent must have a universalIdentifier'); + } + + if (!config.name) { + errors.push('Agent must have a name'); + } + + if (!config.label) { + errors.push('Agent must have a label'); + } + + if (!config.prompt) { + errors.push('Agent must have a prompt'); + } + + return createValidationResult({ config, errors }); +}; diff --git a/packages/twenty-sdk/src/sdk/index.ts b/packages/twenty-sdk/src/sdk/index.ts index b9de5e2642..2f841cc466 100644 --- a/packages/twenty-sdk/src/sdk/index.ts +++ b/packages/twenty-sdk/src/sdk/index.ts @@ -68,6 +68,7 @@ export { definePageLayout } from './page-layouts/define-page-layout'; export type { PageLayoutConfig } from './page-layouts/page-layout-config'; export { defineRole } from './roles/define-role'; export { PermissionFlag } from './roles/permission-flag-type'; +export { defineAgent } from './agents/define-agent'; export { defineSkill } from './skills/define-skill'; export { defineView } from './views/define-view'; export type { ViewConfig } from './views/view-config'; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts index e3ecc4ec3b..a011d5e960 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts @@ -1,6 +1,5 @@ import { type Manifest } from 'twenty-shared/application'; -import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util'; import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util'; import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/engine/core-modules/application/application-manifest/converters/from-front-component-manifest-to-universal-flat-front-component.util'; @@ -18,6 +17,8 @@ import { fromViewFilterGroupManifestToUniversalFlatViewFilterGroup } from 'src/e import { fromViewFilterManifestToUniversalFlatViewFilter } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-manifest-to-universal-flat-view-filter.util'; import { fromViewGroupManifestToUniversalFlatViewGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-group-manifest-to-universal-flat-view-group.util'; import { fromViewManifestToUniversalFlatView } from 'src/engine/core-modules/application/application-manifest/converters/from-view-manifest-to-universal-flat-view.util'; +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { fromAgentManifestToUniversalFlatAgent } from 'src/engine/core-modules/application/utils/from-agent-manifest-to-universal-flat-agent.util'; import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; import { addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/add-universal-flat-entity-to-universal-flat-entity-maps-through-mutation-or-throw.util'; @@ -140,6 +141,17 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({ }); } + for (const agentManifest of manifest.agents ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromAgentManifestToUniversalFlatAgent({ + agentManifest, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: allUniversalFlatEntityMaps.flatAgentMaps, + }); + } + for (const viewManifest of manifest.views ?? []) { addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ universalFlatEntity: fromViewManifestToUniversalFlatView({ diff --git a/packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts b/packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts index 0ba668580c..67ad16a190 100644 --- a/packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts +++ b/packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts @@ -7,6 +7,7 @@ export const APPLICATION_MANIFEST_METADATA_NAMES = [ 'frontComponent', 'role', 'skill', + 'agent', 'view', 'viewField', 'viewFieldGroup', diff --git a/packages/twenty-server/src/engine/core-modules/application/utils/from-agent-manifest-to-universal-flat-agent.util.ts b/packages/twenty-server/src/engine/core-modules/application/utils/from-agent-manifest-to-universal-flat-agent.util.ts new file mode 100644 index 0000000000..aaad523f55 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/utils/from-agent-manifest-to-universal-flat-agent.util.ts @@ -0,0 +1,35 @@ +import { type AgentManifest } from 'twenty-shared/application'; + +import { + DEFAULT_SMART_MODEL, + type ModelId, +} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const'; +import { type UniversalFlatAgent } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-agent.type'; + +export const fromAgentManifestToUniversalFlatAgent = ({ + agentManifest, + applicationUniversalIdentifier, + now, +}: { + agentManifest: AgentManifest; + applicationUniversalIdentifier: string; + now: string; +}): UniversalFlatAgent => { + return { + universalIdentifier: agentManifest.universalIdentifier, + applicationUniversalIdentifier, + name: agentManifest.name, + label: agentManifest.label, + icon: agentManifest.icon ?? null, + description: agentManifest.description ?? null, + prompt: agentManifest.prompt, + modelId: (agentManifest.modelId as ModelId) ?? DEFAULT_SMART_MODEL, + responseFormat: { type: 'text' }, + modelConfiguration: null, + evaluationInputs: [], + isCustom: false, + createdAt: now, + updatedAt: now, + deletedAt: null, + }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-agent.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-agent.integration-spec.ts new file mode 100644 index 0000000000..2b5a2d55e9 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-agent.integration-spec.ts @@ -0,0 +1,240 @@ +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util'; +import { findAgents } from 'test/integration/metadata/suites/agent/utils/find-agents.util'; +import { type Manifest } from 'twenty-shared/application'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const TEST_AGENT_ID = uuidv4(); +const TEST_SECOND_AGENT_ID = uuidv4(); + +const AGENT_GQL_FIELDS = + 'id name label description prompt icon applicationId modelId'; + +const buildManifest = (overrides?: Partial>) => + buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides }); + +const findAppAgents = async () => { + const { data } = await findAgents({ + gqlFields: AGENT_GQL_FIELDS, + expectToFail: false, + input: undefined, + }); + + return data.findManyAgents.filter( + (agent) => + agent.name === 'sales-assistant' || agent.name === 'support-bot', + ); +}; + +describe('Manifest update - agents', () => { + beforeEach(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test Application', + description: 'App for testing agent manifest updates', + sourcePath: 'test-manifest-update-agent', + }); + }, 60000); + + afterEach(async () => { + try { + await uninstallApplication({ + universalIdentifier: TEST_APP_ID, + expectToFail: false, + }); + } catch { + // May fail if the test didn't fully install/sync + } + + await globalThis.testDataSource.query( + `DELETE FROM core."role" WHERE "universalIdentifier" = $1`, + [TEST_ROLE_ID], + ); + + await globalThis.testDataSource.query( + `DELETE FROM core."file" WHERE "applicationId" IN ( + SELECT id FROM core."application" WHERE "universalIdentifier" = $1 + )`, + [TEST_APP_ID], + ); + + await globalThis.testDataSource.query( + `DELETE FROM core."application" + WHERE "universalIdentifier" = $1`, + [TEST_APP_ID], + ); + + await globalThis.testDataSource.query( + `DELETE FROM core."applicationRegistration" + WHERE "universalIdentifier" = $1`, + [TEST_APP_ID], + ); + }); + + it('should create a new agent when added to manifest on second sync', async () => { + await syncApplication({ + manifest: buildManifest({ agents: [] }), + expectToFail: false, + }); + + const agentsAfterFirstSync = await findAppAgents(); + + expect(agentsAfterFirstSync).toHaveLength(0); + + await syncApplication({ + manifest: buildManifest({ + agents: [ + { + universalIdentifier: TEST_AGENT_ID, + name: 'sales-assistant', + label: 'Sales Assistant', + description: 'An AI agent that helps with sales tasks', + icon: 'IconRobot', + prompt: + 'You are a sales assistant. Help users with prospecting and outreach.', + }, + ], + }), + expectToFail: false, + }); + + const agentsAfterSecondSync = await findAppAgents(); + + expect(agentsAfterSecondSync).toHaveLength(1); + expect(agentsAfterSecondSync[0]).toMatchObject({ + name: 'sales-assistant', + label: 'Sales Assistant', + description: 'An AI agent that helps with sales tasks', + icon: 'IconRobot', + prompt: + 'You are a sales assistant. Help users with prospecting and outreach.', + }); + }, 60000); + + it('should update an agent when properties change in manifest on second sync', async () => { + await syncApplication({ + manifest: buildManifest({ + agents: [ + { + universalIdentifier: TEST_AGENT_ID, + name: 'sales-assistant', + label: 'Sales Assistant', + description: 'An AI agent that helps with sales tasks', + icon: 'IconRobot', + prompt: + 'You are a sales assistant. Help users with prospecting and outreach.', + }, + ], + }), + expectToFail: false, + }); + + const agentsAfterFirstSync = await findAppAgents(); + + expect(agentsAfterFirstSync).toHaveLength(1); + expect(agentsAfterFirstSync[0]).toMatchObject({ + label: 'Sales Assistant', + description: 'An AI agent that helps with sales tasks', + }); + + await syncApplication({ + manifest: buildManifest({ + agents: [ + { + universalIdentifier: TEST_AGENT_ID, + name: 'sales-assistant', + label: 'Senior Sales Assistant', + description: 'An advanced AI agent for enterprise sales', + icon: 'IconRobot', + prompt: + 'You are a senior sales assistant specializing in enterprise deals. Help users with complex prospecting, multi-threaded outreach, and deal strategy.', + }, + ], + }), + expectToFail: false, + }); + + const agentsAfterSecondSync = await findAppAgents(); + + expect(agentsAfterSecondSync).toHaveLength(1); + expect(agentsAfterSecondSync[0]).toMatchObject({ + name: 'sales-assistant', + label: 'Senior Sales Assistant', + description: 'An advanced AI agent for enterprise sales', + prompt: + 'You are a senior sales assistant specializing in enterprise deals. Help users with complex prospecting, multi-threaded outreach, and deal strategy.', + }); + }, 60000); + + it('should delete an agent when removed from manifest on second sync', async () => { + await syncApplication({ + manifest: buildManifest({ + agents: [ + { + universalIdentifier: TEST_AGENT_ID, + name: 'sales-assistant', + label: 'Sales Assistant', + description: 'An AI agent that helps with sales tasks', + icon: 'IconRobot', + prompt: + 'You are a sales assistant. Help users with prospecting and outreach.', + }, + { + universalIdentifier: TEST_SECOND_AGENT_ID, + name: 'support-bot', + label: 'Support Bot', + description: 'An AI agent for customer support', + icon: 'IconHeadset', + prompt: + 'You are a customer support agent. Help users resolve their issues.', + }, + ], + }), + expectToFail: false, + }); + + const agentsAfterFirstSync = await findAppAgents(); + + expect(agentsAfterFirstSync).toHaveLength(2); + expect( + agentsAfterFirstSync.find( + (agent) => agent.name === 'sales-assistant', + ), + ).toBeDefined(); + expect( + agentsAfterFirstSync.find((agent) => agent.name === 'support-bot'), + ).toBeDefined(); + + await syncApplication({ + manifest: buildManifest({ + agents: [ + { + universalIdentifier: TEST_AGENT_ID, + name: 'sales-assistant', + label: 'Sales Assistant', + description: 'An AI agent that helps with sales tasks', + icon: 'IconRobot', + prompt: + 'You are a sales assistant. Help users with prospecting and outreach.', + }, + ], + }), + expectToFail: false, + }); + + const agentsAfterSecondSync = await findAppAgents(); + + expect(agentsAfterSecondSync).toHaveLength(1); + expect(agentsAfterSecondSync[0]).toMatchObject({ + name: 'sales-assistant', + label: 'Sales Assistant', + }); + expect( + agentsAfterSecondSync.find((agent) => agent.name === 'support-bot'), + ).toBeUndefined(); + }, 60000); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/utils/build-base-manifest.util.ts b/packages/twenty-server/test/integration/metadata/suites/application/utils/build-base-manifest.util.ts index 6bb5f474d9..f971ca7978 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/utils/build-base-manifest.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/application/utils/build-base-manifest.util.ts @@ -28,6 +28,7 @@ export const buildBaseManifest = ({ }, ], skills: [], + agents: [], objects: [], fields: [], logicFunctions: [], diff --git a/packages/twenty-shared/src/application/agentManifestType.ts b/packages/twenty-shared/src/application/agentManifestType.ts new file mode 100644 index 0000000000..4084226bee --- /dev/null +++ b/packages/twenty-shared/src/application/agentManifestType.ts @@ -0,0 +1,10 @@ +import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; + +export type AgentManifest = SyncableEntityOptions & { + name: string; + label: string; + icon?: string; + description?: string; + prompt: string; + modelId?: string; +}; diff --git a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts index e581459962..eccf35947f 100644 --- a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts +++ b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts @@ -5,6 +5,7 @@ export enum SyncableEntity { FrontComponent = 'frontComponent', Role = 'role', Skill = 'skill', + Agent = 'agent', View = 'view', NavigationMenuItem = 'navigationMenuItem', PageLayout = 'pageLayout', diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index d01505c7cb..7783788448 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -7,6 +7,7 @@ * |___/ */ +export type { AgentManifest } from './agentManifestType'; export type { ApplicationManifest } from './applicationType'; export type { ApplicationVariables } from './applicationVariablesType'; export type { AssetManifest } from './assetManifestType'; diff --git a/packages/twenty-shared/src/application/manifestType.ts b/packages/twenty-shared/src/application/manifestType.ts index a86b08defa..946fe74af1 100644 --- a/packages/twenty-shared/src/application/manifestType.ts +++ b/packages/twenty-shared/src/application/manifestType.ts @@ -1,3 +1,4 @@ +import { type AgentManifest } from './agentManifestType'; import { type ApplicationManifest } from './applicationType'; import { type AssetManifest } from './assetManifestType'; import { type FieldManifest } from './fieldManifestType'; @@ -18,6 +19,7 @@ export type Manifest = { frontComponents: FrontComponentManifest[]; roles: RoleManifest[]; skills: SkillManifest[]; + agents: AgentManifest[]; publicAssets: AssetManifest[]; views: ViewManifest[]; navigationMenuItems: NavigationMenuItemManifest[];