diff --git a/app/validators/json_schemas/ai_catalog/flow_v2.json b/app/validators/json_schemas/ai_catalog/flow_v2.json
new file mode 100644
index 0000000000000000000000000000000000000000..169968de27c45695f9f1aff2af47139c326a037e
--- /dev/null
+++ b/app/validators/json_schemas/ai_catalog/flow_v2.json
@@ -0,0 +1,537 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Flow Registry v1 Configuration Schema",
+ "description": "JSON Schema for validating Flow Registry v1 YAML configuration files",
+ "type": "object",
+ "required": [
+ "version",
+ "environment",
+ "components",
+ "routers",
+ "flow",
+ "yaml_definition"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "version": {
+ "type": "string",
+ "const": "v1",
+ "description": "Framework version - must be 'v1' for current stable version"
+ },
+ "environment": {
+ "type": "string",
+ "enum": [
+ "ambient"
+ ],
+ "description": "Flow environment declaring expected level of interaction between human and AI agent"
+ },
+ "components": {
+ "type": "array",
+ "minItems": 1,
+ "description": "List of components that make up the flow",
+ "items": {
+ "oneOf": [
+ {
+ "$ref": "#/definitions/AgentComponent"
+ },
+ {
+ "$ref": "#/definitions/DeterministicStepComponent"
+ },
+ {
+ "$ref": "#/definitions/OneOffComponent"
+ }
+ ]
+ }
+ },
+ "routers": {
+ "type": "array",
+ "description": "Define how components connect to each other",
+ "items": {
+ "$ref": "#/definitions/Router"
+ }
+ },
+ "flow": {
+ "type": "object",
+ "description": "Specify the entry point component and other flow options",
+ "properties": {
+ "entry_point": {
+ "type": "string",
+ "description": "Name of first component to run. Examples: 'main_agent', 'initial_step'",
+ "pattern": "^[a-zA-Z0-9_]+$"
+ },
+ "inputs": {
+ "type": "array",
+ "description": "Optional additional context schema definitions that can be passed to the flow (in addition to the 'goal')",
+ "items": {
+ "$ref": "#/definitions/FlowInputCategory"
+ }
+ }
+ },
+ "additionalProperties": false
+ },
+ "prompts": {
+ "type": "array",
+ "description": "List of inline prompt templates for flow components to use",
+ "items": {
+ "$ref": "#/definitions/LocalPrompt"
+ }
+ },
+ "yaml_definition": {
+ "type": "string"
+ }
+ },
+ "definitions": {
+ "ComponentName": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_]+$",
+ "description": "Component name must use alphanumeric characters or underscore. Must not include characters such as : and . Examples: 'my_agent', 'step1', 'dataProcessor'"
+ },
+ "AgentComponent": {
+ "type": "object",
+ "required": [
+ "name",
+ "type",
+ "prompt_id"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "$ref": "#/definitions/ComponentName"
+ },
+ "type": {
+ "type": "string",
+ "const": "AgentComponent"
+ },
+ "prompt_id": {
+ "type": "string",
+ "description": "ID of the prompt template from either the prompt registry or locally defined prompts"
+ },
+ "prompt_version": {
+ "oneOf": [
+ {
+ "type": "string",
+ "pattern": "^[~^]?\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$",
+ "description": "Semantic version constraint (e.g., '^1.0.0')"
+ },
+ {
+ "type": "null",
+ "description": "Use locally defined prompt from flow YAML"
+ }
+ ]
+ },
+ "inputs": {
+ "type": "array",
+ "description": "List of input data sources",
+ "default": [
+ "context:goal"
+ ],
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Simple input reference"
+ },
+ {
+ "$ref": "#/definitions/InputMapping"
+ }
+ ]
+ }
+ },
+ "toolset": {
+ "type": "array",
+ "description": "List of tools available to the agent. Examples: ['read_file', 'list_dir', 'edit_file']",
+ "items": {
+ "type": "string",
+ "description": "Tool name from tools registry"
+ }
+ },
+ "ui_log_events": {
+ "type": "array",
+ "description": "UI logging configuration",
+ "items": {
+ "type": "string",
+ "enum": [
+ "on_agent_final_answer",
+ "on_tool_execution_success",
+ "on_tool_execution_failed"
+ ]
+ }
+ },
+ "ui_role_as": {
+ "type": "string",
+ "enum": [
+ "agent",
+ "tool"
+ ],
+ "description": "Display role in UI"
+ }
+ }
+ },
+ "DeterministicStepComponent": {
+ "type": "object",
+ "required": [
+ "name",
+ "type",
+ "tool_name"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "$ref": "#/definitions/ComponentName"
+ },
+ "type": {
+ "type": "string",
+ "const": "DeterministicStepComponent"
+ },
+ "tool_name": {
+ "type": "string",
+ "description": "Name of the single tool to execute"
+ },
+ "toolset": {
+ "type": "array",
+ "description": "Toolset containing the tool to be executed",
+ "items": {
+ "type": "string"
+ }
+ },
+ "inputs": {
+ "type": "array",
+ "description": "List of input data sources to extract tool parameters",
+ "default": [],
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Simple input reference. Examples: 'context:goal'"
+ },
+ {
+ "$ref": "#/definitions/InputMapping"
+ }
+ ]
+ }
+ },
+ "ui_log_events": {
+ "type": "array",
+ "description": "UI logging configuration for displaying tool execution",
+ "items": {
+ "type": "string",
+ "enum": [
+ "on_tool_execution_success",
+ "on_tool_execution_failed"
+ ]
+ }
+ },
+ "ui_role_as": {
+ "type": "string",
+ "enum": [
+ "agent",
+ "tool"
+ ],
+ "default": "tool",
+ "description": "Display role in UI"
+ }
+ }
+ },
+ "OneOffComponent": {
+ "type": "object",
+ "required": [
+ "name",
+ "type",
+ "prompt_id",
+ "toolset"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "$ref": "#/definitions/ComponentName"
+ },
+ "type": {
+ "type": "string",
+ "const": "OneOffComponent"
+ },
+ "prompt_id": {
+ "type": "string",
+ "description": "ID of the prompt template from either the prompt registry or locally defined prompts"
+ },
+ "prompt_version": {
+ "oneOf": [
+ {
+ "type": "string",
+ "pattern": "^[~^]?\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$",
+ "description": "Semantic version constraint. Examples: '1.0.0' (exact), '^1.2.3' (compatible)"
+ },
+ {
+ "type": "null",
+ "description": "Use locally defined prompt from flow YAML"
+ }
+ ]
+ },
+ "toolset": {
+ "type": "array",
+ "minItems": 1,
+ "description": "List of tools available to the component. Examples: ['read_file', 'list_dir', 'edit_file']",
+ "items": {
+ "type": "string",
+ "description": "Tool name from tools registry"
+ }
+ },
+ "inputs": {
+ "type": "array",
+ "description": "List of input data sources",
+ "default": [
+ "context:goal"
+ ],
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Simple input reference. Examples: 'context:goal'"
+ },
+ {
+ "$ref": "#/definitions/InputMapping"
+ }
+ ]
+ }
+ },
+ "max_correction_attempts": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 3,
+ "description": "Maximum number of retry attempts for failed tool executions"
+ },
+ "ui_log_events": {
+ "type": "array",
+ "description": "UI logging configuration for displaying tool execution progress",
+ "items": {
+ "type": "string",
+ "enum": [
+ "on_tool_call_input",
+ "on_tool_execution_success",
+ "on_tool_execution_failed"
+ ]
+ }
+ }
+ }
+ },
+ "InputMapping": {
+ "type": "object",
+ "required": [
+ "from",
+ "as"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "from": {
+ "type": "string",
+ "description": "Source of the input data. Examples: 'context:goal'"
+ },
+ "as": {
+ "type": "string",
+ "description": "Variable name to use in prompt template. Examples: 'user_goal'"
+ },
+ "literal": {
+ "type": "boolean",
+ "description": "Whether the 'from' value should be treated as a literal value"
+ }
+ }
+ },
+ "Router": {
+ "type": "object",
+ "required": [
+ "from"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "from": {
+ "type": "string",
+ "description": "Source component name. Examples: 'main_agent', 'data_processor'"
+ },
+ "to": {
+ "type": "string",
+ "description": "Target component name or 'end'. Examples: 'next_step', 'error_handler', 'end'"
+ },
+ "condition": {
+ "$ref": "#/definitions/RouterCondition"
+ }
+ },
+ "oneOf": [
+ {
+ "required": [
+ "to"
+ ]
+ },
+ {
+ "required": [
+ "condition"
+ ]
+ }
+ ]
+ },
+ "RouterCondition": {
+ "type": "object",
+ "required": [
+ "input",
+ "routes"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "input": {
+ "type": "string",
+ "description": "Input to evaluate for routing decision"
+ },
+ "routes": {
+ "type": "object",
+ "description": "Mapping of condition values to target components",
+ "patternProperties": {
+ ".*": {
+ "type": "string",
+ "description": "Target component name or 'end'"
+ }
+ }
+ }
+ }
+ },
+ "LocalPrompt": {
+ "type": "object",
+ "required": [
+ "prompt_id",
+ "name",
+ "model",
+ "prompt_template"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "prompt_id": {
+ "type": "string",
+ "description": "Unique identifier for the local prompt"
+ },
+ "name": {
+ "type": "string",
+ "description": "name for the local prompt"
+ },
+ "model": {
+ "$ref": "#/definitions/ModelConfig"
+ },
+ "prompt_template": {
+ "$ref": "#/definitions/PromptTemplate"
+ },
+ "params": {
+ "type": "object",
+ "properties": {
+ "timeout": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Timeout in seconds for prompt execution"
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+ },
+ "ModelConfig": {
+ "type": "object",
+ "required": [
+ "params"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "params": {
+ "type": "object",
+ "required": [
+ "model_class_provider"
+ ],
+ "properties": {
+ "model_class_provider": {
+ "type": "string",
+ "description": "Model provider. Examples: 'anthropic'"
+ },
+ "model": {
+ "type": "string",
+ "description": "Specific model name. Examples: 'claude-3-sonnet'"
+ },
+ "max_tokens": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Maximum number of tokens for model response"
+ }
+ },
+ "additionalProperties": false
+ },
+ "unit_primitives": {
+ "type": "array",
+ "description": "Unit primitives configuration",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "PromptTemplate": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "system": {
+ "type": "string",
+ "description": "System message template"
+ },
+ "user": {
+ "type": "string",
+ "description": "User message template"
+ },
+ "placeholder": {
+ "type": "string",
+ "enum": [
+ "history"
+ ],
+ "description": "Message placeholder for conversation history"
+ }
+ }
+ },
+ "FlowInputCategory": {
+ "type": "object",
+ "required": [
+ "category",
+ "input_schema"
+ ],
+ "additionalProperties": false,
+ "description": "Defines a category of additional context inputs that can be passed to the flow",
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "Category name for the additional context. Examples: 'merge_request_info', 'pipeline_info'"
+ },
+ "input_schema": {
+ "type": "object",
+ "description": "Schema definition for the inputs in this category",
+ "patternProperties": {
+ ".*": {
+ "$ref": "#/definitions/FlowInputField"
+ }
+ }
+ }
+ }
+ },
+ "FlowInputField": {
+ "type": "object",
+ "required": [
+ "type"
+ ],
+ "additionalProperties": false,
+ "description": "Schema definition for a single input field",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "JSON Schema type for the field. Examples: 'string'"
+ },
+ "format": {
+ "type": "string",
+ "description": "Optional JSON Schema format specifier. Examples: 'uri', 'email', 'date-time'"
+ },
+ "description": {
+ "type": "string",
+ "description": "Optional description of the field's purpose"
+ }
+ }
+ }
+ }
+}
diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md
index 0f4176ec16c907f285d8398a0746bafef44577cc..04c55c7c57e41b81e1acd2a8d28a19ff7de1e4e4 100644
--- a/doc/api/graphql/reference/_index.md
+++ b/doc/api/graphql/reference/_index.md
@@ -2648,12 +2648,13 @@ Input type: `AiCatalogFlowCreateInput`
| ---- | ---- | ----------- |
| `addToProjectWhenCreated` | [`Boolean`](#boolean) | Whether to add to the project upon creation. |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `definition` | [`String`](#string) | YAML definition for the flow. |
| `description` | [`String!`](#string) | Description for the flow. |
| `name` | [`String!`](#string) | Name for the flow. |
| `projectId` | [`ProjectID!`](#projectid) | Project for the flow. |
| `public` | [`Boolean!`](#boolean) | Whether the flow is publicly visible in the catalog. |
| `release` | [`Boolean`](#boolean) | Whether to release the latest version of the flow. |
-| `steps` | [`[AiCatalogFlowStepsInput!]!`](#aicatalogflowstepsinput) | Steps for the flow. |
+| `steps` | [`[AiCatalogFlowStepsInput!]`](#aicatalogflowstepsinput) | Steps for the flow. |
#### Fields
@@ -2727,6 +2728,7 @@ Input type: `AiCatalogFlowUpdateInput`
| Name | Type | Description |
| ---- | ---- | ----------- |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `definition` | [`String`](#string) | YAML definition for the Flow. |
| `description` | [`String`](#string) | Description for the flow. |
| `id` | [`AiCatalogItemID!`](#aicatalogitemid) | Global ID of the catalog flow to update. |
| `name` | [`String`](#string) | Name for the flow. |
@@ -23839,11 +23841,12 @@ An AI catalog flow version.
| Name | Type | Description |
| ---- | ---- | ----------- |
| `createdAt` | [`Time!`](#time) | Timestamp of when the item version was created. |
+| `definition` | [`String`](#string) | YAML definition of the flow. |
| `humanVersionName` | [`String`](#string) | Human-friendly name of the item version. In the form v1.0.0-draft. |
| `id` | [`ID!`](#id) | ID of the item version. |
| `released` | [`Boolean!`](#boolean) | Indicates the item version is released. |
| `releasedAt` | [`Time`](#time) | Timestamp of when the item version was released. |
-| `steps` | [`AiCatalogFlowStepsConnection!`](#aicatalogflowstepsconnection) | Steps of the flow. (see [Connections](#connections)) |
+| `steps` | [`AiCatalogFlowStepsConnection`](#aicatalogflowstepsconnection) | Steps of the flow. (see [Connections](#connections)) |
| `updatedAt` | [`Time!`](#time) | Timestamp of when the item version was updated. |
| `versionName` | [`String`](#string) | Version name of the item version. |
diff --git a/ee/app/graphql/mutations/ai/catalog/flow/create.rb b/ee/app/graphql/mutations/ai/catalog/flow/create.rb
index 65e7e098d554ed6f9d97058ee4b1e7f2b78739d0..1e86f34e02831c859088b1808ffd3b439e263bfe 100644
--- a/ee/app/graphql/mutations/ai/catalog/flow/create.rb
+++ b/ee/app/graphql/mutations/ai/catalog/flow/create.rb
@@ -35,9 +35,13 @@ class Create < BaseMutation
description: 'Whether to release the latest version of the flow.'
argument :steps, [::Types::Ai::Catalog::FlowStepsInputType],
- required: true,
+ required: false,
description: 'Steps for the flow.'
+ argument :definition, GraphQL::Types::String,
+ required: false,
+ description: 'YAML definition for the flow.'
+
argument :add_to_project_when_created, GraphQL::Types::Boolean,
required: false,
description: 'Whether to add to the project upon creation.'
@@ -48,12 +52,6 @@ def resolve(args)
project = authorized_find!(id: args[:project_id])
service_args = args.except(:project_id)
- # We can't use `loads` because of this bug https://github.com/rmosolgo/graphql-ruby/issues/2966
- agents = ::Ai::Catalog::Item.with_ids(service_args[:steps].pluck(:agent_id)).index_by(&:id) # rubocop:disable CodeReuse/ActiveRecord -- not an ActiveRecord model
-
- service_args[:steps] = service_args[:steps].map do |step|
- step.to_hash.merge(agent: agents[step[:agent_id]]).except(:agent_id)
- end
result = ::Ai::Catalog::Flows::CreateService.new(
project: project,
diff --git a/ee/app/graphql/mutations/ai/catalog/flow/update.rb b/ee/app/graphql/mutations/ai/catalog/flow/update.rb
index 2f1ee4c78bc8cc9c56d80b4f693ee5ebcbe36b77..d42d868eadfc2cafa2448a97c0bfc81eef84ea50 100644
--- a/ee/app/graphql/mutations/ai/catalog/flow/update.rb
+++ b/ee/app/graphql/mutations/ai/catalog/flow/update.rb
@@ -36,6 +36,10 @@ class Update < BaseMutation
required: false,
description: 'Steps for the flow.'
+ argument :definition, GraphQL::Types::String,
+ required: false,
+ description: 'YAML definition for the Flow.'
+
argument :version_bump, Types::Ai::Catalog::VersionBumpEnum,
required: false,
description: 'Bump version, calculated from the last released version name.'
@@ -47,15 +51,6 @@ def resolve(args)
params = args.except(:id).merge(item: flow)
- unless params[:steps].nil?
- # We can't use `loads` because of this bug https://github.com/rmosolgo/graphql-ruby/issues/2966
- agents = ::Ai::Catalog::Item.with_ids(params[:steps].pluck(:agent_id)).index_by(&:id) # rubocop:disable CodeReuse/ActiveRecord -- not an ActiveRecord model
-
- params[:steps] = params[:steps].map do |step|
- step.to_hash.merge(agent: agents[step[:agent_id]]).except(:agent_id)
- end
- end
-
result = ::Ai::Catalog::Flows::UpdateService.new(
project: flow.project,
current_user: current_user,
diff --git a/ee/app/graphql/types/ai/catalog/flow_version_type.rb b/ee/app/graphql/types/ai/catalog/flow_version_type.rb
index 1781652c6ccd2e3c6c3fa8eade08a4a54883b164..d033af00e4f4aa7c7d77f3f2de28176c965f208c 100644
--- a/ee/app/graphql/types/ai/catalog/flow_version_type.rb
+++ b/ee/app/graphql/types/ai/catalog/flow_version_type.rb
@@ -10,10 +10,18 @@ class FlowVersionType < ::Types::BaseObject
field :steps, FlowStepsType.connection_type,
method: :def_steps,
- null: false,
+ null: true,
description: 'Steps of the flow.'
+ field :definition, GraphQL::Types::String,
+ null: true,
+ description: 'YAML definition of the flow.'
+
implements ::Types::Ai::Catalog::VersionInterface
+
+ def definition
+ object.definition['yaml_definition']
+ end
end
end
end
diff --git a/ee/app/models/ai/catalog/item_version.rb b/ee/app/models/ai/catalog/item_version.rb
index 6379ac6219f179f5fc26d1af733f639384907045..ec905a0e2063c1243f89376aa465938a2171ac1c 100644
--- a/ee/app/models/ai/catalog/item_version.rb
+++ b/ee/app/models/ai/catalog/item_version.rb
@@ -3,10 +3,9 @@
module Ai
module Catalog
class ItemVersion < ApplicationRecord
- include ::Ai::Catalog::Concerns::FlowVersion
-
AGENT_SCHEMA_VERSION = 1
- FLOW_SCHEMA_VERSION = 1
+ AGENT_REFERENCED_FLOW_SCHEMA_VERSION = 1
+ FLOW_SCHEMA_VERSION = 2
THIRD_PARTY_FLOW_SCHEMA_VERSION = 1
DEFINITION_ACCESSOR_PREFIX = 'def_'
diff --git a/ee/app/services/ai/catalog/concerns/yaml_definition_parser.rb b/ee/app/services/ai/catalog/concerns/yaml_definition_parser.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e8ca67bbe57eb4af4750dac8895a84dcfac7e17f
--- /dev/null
+++ b/ee/app/services/ai/catalog/concerns/yaml_definition_parser.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Ai
+ module Catalog
+ module Concerns
+ module YamlDefinitionParser
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ private
+
+ def definition_parsed
+ return unless params[:definition].present?
+
+ YAML.safe_load(params[:definition]).merge(yaml_definition: params[:definition])
+ rescue Psych::SyntaxError
+ nil
+ end
+ strong_memoize_attr :definition_parsed
+
+ def valid_yaml_definition?
+ return true unless params.key?(:definition)
+
+ definition_parsed.present?
+ end
+
+ def yaml_syntax_error(item_type = 'Flow')
+ error("#{item_type} definition does not have a valid YAML syntax")
+ end
+
+ def parsed_yaml_definition_or_error(item_type = 'Flow')
+ return yaml_syntax_error(item_type) unless valid_yaml_definition?
+
+ definition_parsed
+ end
+
+ def parsed_definition_param
+ return {} unless should_update_definition?
+
+ { definition: definition_parsed }
+ end
+
+ def should_update_definition?
+ params.key?(:definition) && definition_parsed.present?
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/services/ai/catalog/execute_workflow_service.rb b/ee/app/services/ai/catalog/execute_workflow_service.rb
index 799bbdb4c149dbeec96d79f6e685a4c24a89c2eb..30df1e3c0137400c6cd135726f76a74927a8fd64 100644
--- a/ee/app/services/ai/catalog/execute_workflow_service.rb
+++ b/ee/app/services/ai/catalog/execute_workflow_service.rb
@@ -5,7 +5,6 @@ module Catalog
class ExecuteWorkflowService
include Gitlab::Utils::StrongMemoize
- FLOW_CONFIG_VERSION = 'experimental'
WORKFLOW_ENVIRONMENT = 'web'
AGENT_PRIVILEGES = [
DuoWorkflows::Workflow::AgentPrivileges::READ_WRITE_FILES,
@@ -99,7 +98,7 @@ def build_start_workflow_params(workflow)
{
goal: goal,
flow_config: json_config,
- flow_config_schema_version: FLOW_CONFIG_VERSION,
+ flow_config_schema_version: flow_config_schema_version,
workflow_id: workflow.id,
workflow_oauth_token: oauth_token_result.payload[:oauth_access_token].plaintext_token,
workflow_service_token: workflow_token_result.payload[:token],
@@ -126,6 +125,10 @@ def determine_workflow_definition
def allowed?
Ability.allowed?(current_user, :execute_ai_catalog_item_version, item_version)
end
+
+ def flow_config_schema_version
+ 'v1'
+ end
end
end
end
diff --git a/ee/app/services/ai/catalog/flows/create_service.rb b/ee/app/services/ai/catalog/flows/create_service.rb
index fe73a2c3e6aadd8cbfe82b89bf48f81386531424..c9202f1015f71e0c1be4b19814e0e964c19b11ae 100644
--- a/ee/app/services/ai/catalog/flows/create_service.rb
+++ b/ee/app/services/ai/catalog/flows/create_service.rb
@@ -5,12 +5,10 @@ module Catalog
module Flows
class CreateService < Ai::Catalog::BaseService
include FlowHelper
+ include Concerns::YamlDefinitionParser
def execute
return error_no_permissions unless allowed?
- return error(MAX_STEPS_ERROR) if max_steps_exceeded?
- return error_no_permissions unless agents_allowed?
- return error(steps_validation_errors) unless steps_valid?
item_params = params.slice(:name, :description, :public)
item_params.merge!(
@@ -18,13 +16,14 @@ def execute
organization_id: project.organization_id,
project_id: project.id
)
+
+ definition = parsed_yaml_definition_or_error
+ return definition if definition.is_a?(ServiceResponse)
+
version_params = {
schema_version: ::Ai::Catalog::ItemVersion::FLOW_SCHEMA_VERSION,
version: DEFAULT_VERSION,
- definition: {
- triggers: [],
- steps: steps
- }
+ definition: definition
}
version_params[:release_date] = Time.zone.now if params[:release] == true
@@ -58,7 +57,6 @@ def save_item(item)
Ai::Catalog::Item.transaction do
item.save!
item.update!(latest_released_version: item.latest_version) if item.latest_version.released?
- populate_dependencies(item.latest_version, delete_no_longer_used_dependencies: false)
true
end
rescue ActiveRecord::RecordInvalid
diff --git a/ee/app/services/ai/catalog/flows/execute_service.rb b/ee/app/services/ai/catalog/flows/execute_service.rb
index 7b1a46ea9949980515e52fe56e45776280649209..d522f01f188a9be015d2de4f787ae5e4186f460b 100644
--- a/ee/app/services/ai/catalog/flows/execute_service.rb
+++ b/ee/app/services/ai/catalog/flows/execute_service.rb
@@ -47,7 +47,6 @@ def validate
return error('Flow is required') unless flow && flow.flow?
return error('Flow version is required') unless flow_version
return error('Flow version must belong to the flow') unless flow_version.item == flow
- return error('Flow version must have steps') unless flow_version.def_steps.present?
return error('Trigger event type is required') if event_type.blank?
ServiceResponse.success
@@ -66,16 +65,11 @@ def execute_workflow_service(flow_config)
end
def generate_flow_config
- payload_builder = ::Ai::Catalog::DuoWorkflowPayloadBuilder::Experimental.new(
- flow,
- flow_version.version,
- { user_prompt_input: user_prompt }
- )
- payload_builder.build
+ flow_version.definition.except('yaml_definition')
end
def flow_goal
- flow.description
+ user_prompt || flow.description
end
end
end
diff --git a/ee/app/services/ai/catalog/flows/update_service.rb b/ee/app/services/ai/catalog/flows/update_service.rb
index c64813be88ff8ee68d75df4370d668b5b94269a5..34784a0b83542791369ddd309556b0d8a3db7b36 100644
--- a/ee/app/services/ai/catalog/flows/update_service.rb
+++ b/ee/app/services/ai/catalog/flows/update_service.rb
@@ -6,32 +6,25 @@ module Flows
class UpdateService < Items::BaseUpdateService
extend Gitlab::Utils::Override
include FlowHelper
+ include Concerns::YamlDefinitionParser
private
override :validate_item
def validate_item
return error('Flow not found') unless item && item.flow?
- return error(MAX_STEPS_ERROR) if max_steps_exceeded?
- return error_no_permissions(payload: payload) unless agents_allowed?
- error(steps_validation_errors) unless steps_valid?
+ yaml_syntax_error unless valid_yaml_definition?
end
override :build_version_params
- def build_version_params(latest_version)
- return {} unless params.key?(:steps)
-
- {
- definition: latest_version.definition.merge(steps: steps)
- }
+ def build_version_params(_latest_version)
+ parsed_definition_param
end
override :save_item
def save_item
- Ai::Catalog::Item.transaction do
- populate_dependencies(item.latest_version) if item.save && item.latest_version.saved_changes?
- end
+ item.save
end
override :latest_schema_version
diff --git a/ee/app/services/ai/catalog/third_party_flows/create_service.rb b/ee/app/services/ai/catalog/third_party_flows/create_service.rb
index 7e150378a47d4af2ff8b75532dd6d099c72efc9f..831d20af243c4becc44782fd25304142973049fa 100755
--- a/ee/app/services/ai/catalog/third_party_flows/create_service.rb
+++ b/ee/app/services/ai/catalog/third_party_flows/create_service.rb
@@ -4,6 +4,8 @@ module Ai
module Catalog
module ThirdPartyFlows
class CreateService < Ai::Catalog::BaseService
+ include Concerns::YamlDefinitionParser
+
def execute
return error_no_permissions unless allowed?
@@ -14,11 +16,8 @@ def execute
project_id: project.id
)
- begin
- definition = YAML.safe_load(params[:definition]).merge(yaml_definition: params[:definition])
- rescue Psych::SyntaxError
- return error('definition does not have a valid YAML syntax')
- end
+ definition = parsed_yaml_definition_or_error('ThirdPartyFlow')
+ return definition if definition.is_a?(ServiceResponse)
version_params = {
schema_version: Ai::Catalog::ItemVersion::THIRD_PARTY_FLOW_SCHEMA_VERSION,
diff --git a/ee/app/services/ai/catalog/third_party_flows/update_service.rb b/ee/app/services/ai/catalog/third_party_flows/update_service.rb
index ad555a5bed51f6969ab9f67561879b64b4836b4a..5d955dfab027035dc67b5fc08f3fe9292e68a6fe 100644
--- a/ee/app/services/ai/catalog/third_party_flows/update_service.rb
+++ b/ee/app/services/ai/catalog/third_party_flows/update_service.rb
@@ -5,6 +5,7 @@ module Catalog
module ThirdPartyFlows
class UpdateService < Items::BaseUpdateService
extend Gitlab::Utils::Override
+ include Concerns::YamlDefinitionParser
private
@@ -12,14 +13,12 @@ class UpdateService < Items::BaseUpdateService
def validate_item
return error('ThirdPartyFlow not found') unless item && item.third_party_flow?
- error('ThirdPartyFlow definition does not have a valid YAML syntax') unless valid_definition?
+ yaml_syntax_error('ThirdPartyFlow') unless valid_yaml_definition?
end
override :build_version_params
def build_version_params(_latest_version)
- return {} unless definition_parsed.present?
-
- { definition: definition_parsed }
+ parsed_definition_param
end
override :save_item
@@ -31,19 +30,6 @@ def save_item
def latest_schema_version
Ai::Catalog::ItemVersion::THIRD_PARTY_FLOW_SCHEMA_VERSION
end
-
- def valid_definition?
- definition_parsed
- true
- rescue Psych::SyntaxError
- false
- end
-
- strong_memoize_attr def definition_parsed
- return unless params[:definition].present?
-
- YAML.safe_load(params[:definition]).merge(yaml_definition: params[:definition])
- end
end
end
end
diff --git a/ee/app/services/ai/catalog/wrapped_agent_flow_builder.rb b/ee/app/services/ai/catalog/wrapped_agent_flow_builder.rb
index 25b7de1751e49e70d1a7e72247654222397efdcb..2239cddfb8418df7afa53b39fe157eed40b9510e 100644
--- a/ee/app/services/ai/catalog/wrapped_agent_flow_builder.rb
+++ b/ee/app/services/ai/catalog/wrapped_agent_flow_builder.rb
@@ -57,7 +57,7 @@ def build_flow_version
::Ai::Catalog::ItemVersion.new(
item: flow,
definition: build_flow_definition,
- schema_version: ::Ai::Catalog::ItemVersion::FLOW_SCHEMA_VERSION,
+ schema_version: ::Ai::Catalog::ItemVersion::AGENT_REFERENCED_FLOW_SCHEMA_VERSION,
version: GENERATED_FLOW_VERSION
)
end
diff --git a/ee/app/services/ai/flow_triggers/run_service.rb b/ee/app/services/ai/flow_triggers/run_service.rb
index e7fc051bb42735bebbfce9ab920f690573f06a58..4932a88576527d4dc2bc3e61b56855046c28db27 100644
--- a/ee/app/services/ai/flow_triggers/run_service.rb
+++ b/ee/app/services/ai/flow_triggers/run_service.rb
@@ -110,7 +110,7 @@ def start_catalog_workflow(params)
flow: catalog_item,
flow_version: catalog_item.resolve_version(catalog_item_pinned_version),
event_type: params[:event].to_s,
- user_prompt: catalog_item_user_prompt(params[:input]),
+ user_prompt: catalog_item_user_prompt(params[:input], params[:event]),
execute_workflow: true
}
).execute
@@ -213,8 +213,12 @@ def gitlab_hostname
# Pass the user input and the current context as a user prompt to a catalog item
# Ideally, it should be prompt variables for better flexibility
- def catalog_item_user_prompt(user_input)
- "Input: #{user_input}\nContext: #{serialized_resource}"
+ def catalog_item_user_prompt(user_input, event_type)
+ if event_type == :mention
+ "Input: #{user_input}\nContext: #{serialized_resource}"
+ else
+ user_input
+ end
end
end
end
diff --git a/ee/app/services/ee/issuable_base_service.rb b/ee/app/services/ee/issuable_base_service.rb
index 9441af58757891ecc0b4b7a7949d56da26748f71..3539f80fb9d9df5038b0233547b08e880b020313 100644
--- a/ee/app/services/ee/issuable_base_service.rb
+++ b/ee/app/services/ee/issuable_base_service.rb
@@ -72,7 +72,7 @@ def execute_flow_triggers(issuable, users, event_type)
current_user: current_user,
resource: issuable,
flow_trigger: flow_trigger
- ).execute({ input: "", event: event_type })
+ ).execute({ input: issuable.iid.to_s, event: event_type })
end
end
diff --git a/ee/spec/factories/ai/catalog/item_versions.rb b/ee/spec/factories/ai/catalog/item_versions.rb
index 28658efc9952f046298ef4f9fe79d5d5c70d52d3..ca61adbe217191c56f8c3bcaf1de7c8f92f5dcfc 100644
--- a/ee/spec/factories/ai/catalog/item_versions.rb
+++ b/ee/spec/factories/ai/catalog/item_versions.rb
@@ -9,6 +9,7 @@
factory :ai_catalog_agent_version, traits: [:for_agent]
factory :ai_catalog_flow_version, traits: [:for_flow]
+ factory :ai_catalog_agent_referenced_flow_version, traits: [:for_agent_referenced_flow]
factory :ai_catalog_third_party_flow_version, traits: [:for_third_party_flow]
trait :released do
@@ -30,16 +31,46 @@
end
end
- trait :for_flow do
+ trait :for_agent_referenced_flow do
+ schema_version { ::Ai::Catalog::ItemVersion::AGENT_REFERENCED_FLOW_SCHEMA_VERSION }
item { association :ai_catalog_flow }
definition do
- agent = Ai::Catalog::Item.find_by(item_type: :agent) || create(:ai_catalog_agent) # rubocop:disable RSpec/FactoryBot/InlineAssociation -- Not used for an association
+ {
+ 'triggers' => [],
+ 'steps' => []
+ }
+ end
+ end
+ trait :for_flow do
+ schema_version { ::Ai::Catalog::ItemVersion::FLOW_SCHEMA_VERSION }
+ item { association :ai_catalog_flow }
+ definition do
{
- 'triggers' => [1],
- 'steps' => [
- { 'agent_id' => agent.id, 'current_version_id' => agent.latest_version.id, 'pinned_version_prefix' => nil }
- ]
+ 'version' => 'v1',
+ 'environment' => 'ambient',
+ 'components' => [
+ {
+ 'name' => 'main_agent',
+ 'type' => 'AgentComponent',
+ 'prompt_id' => 'test_prompt'
+ }
+ ],
+ 'routers' => [],
+ 'flow' => {
+ 'entry_point' => 'main_agent'
+ },
+ 'yaml_definition' => <<~YAML
+ version: v1
+ environment: ambient
+ components:
+ - name: main_agent
+ type: AgentComponent
+ prompt_id: test_prompt
+ routers: []
+ flow:
+ entry_point: main_agent
+ YAML
}
end
end
diff --git a/ee/spec/graphql/mutations/ai/catalog/flow/create_spec.rb b/ee/spec/graphql/mutations/ai/catalog/flow/create_spec.rb
index 7da7519f97f887d38c1eafa0b222d3863fb3ddd4..fcd00bb9e0e2726385ea31826693b619e063cf7d 100644
--- a/ee/spec/graphql/mutations/ai/catalog/flow/create_spec.rb
+++ b/ee/spec/graphql/mutations/ai/catalog/flow/create_spec.rb
@@ -21,6 +21,7 @@
:public,
:release,
:steps,
+ :definition,
:client_mutation_id,
:add_to_project_when_created
)
diff --git a/ee/spec/graphql/mutations/ai/catalog/flow/update_spec.rb b/ee/spec/graphql/mutations/ai/catalog/flow/update_spec.rb
index 06c517c86c5ad781aa611dbda630afd0458a57d4..fc4e2a59a8b7bd4871813c1f5bdbbf727bfd9907 100644
--- a/ee/spec/graphql/mutations/ai/catalog/flow/update_spec.rb
+++ b/ee/spec/graphql/mutations/ai/catalog/flow/update_spec.rb
@@ -21,6 +21,7 @@
:public,
:release,
:steps,
+ :definition,
:version_bump,
:client_mutation_id
)
diff --git a/ee/spec/graphql/types/ai/catalog/flow_version_type_spec.rb b/ee/spec/graphql/types/ai/catalog/flow_version_type_spec.rb
index 318b769970457d903c0d2d2a979b47ea36779f1e..0ec0d49e04d5f2ab5301fa508fae457e6247c19d 100644
--- a/ee/spec/graphql/types/ai/catalog/flow_version_type_spec.rb
+++ b/ee/spec/graphql/types/ai/catalog/flow_version_type_spec.rb
@@ -3,19 +3,30 @@
require 'spec_helper'
RSpec.describe Types::Ai::Catalog::FlowVersionType, feature_category: :workflow_catalog do
- it 'has the correct name' do
- expect(described_class.graphql_name).to eq('AiCatalogFlowVersion')
- end
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project, maintainers: [current_user]) }
+ let_it_be(:item) { create(:ai_catalog_item, :flow, project: project, public: true) }
- it 'implements the correct interface' do
- expect(described_class.interfaces).to include(Types::Ai::Catalog::VersionInterface)
+ let(:query) do
+ %{
+ query {
+ aiCatalogItem(id: "#{item.to_global_id}") {
+ latestVersion {
+ ... on #{described_class.graphql_name} {
+ definition
+ }
+ }
+ }
+ }
+ }
end
- it 'has the expected fields' do
- expect(described_class.own_fields.keys).to match_array(%w[
- steps
- ])
- end
+ let(:returned_definition) { subject.dig('data', 'aiCatalogItem', 'latestVersion', 'definition') }
+
+ subject { GitlabSchema.execute(query, context: { current_user: }).as_json }
+
+ specify { expect(described_class.graphql_name).to eq('AiCatalogFlowVersion') }
+ specify { expect(described_class.interfaces).to include(::Types::Ai::Catalog::VersionInterface) }
- it { expect(described_class).to require_graphql_authorizations(:read_ai_catalog_item) }
+ it_behaves_like 'AI catalog version definition field'
end
diff --git a/ee/spec/graphql/types/ai/catalog/third_party_flow_type_spec.rb b/ee/spec/graphql/types/ai/catalog/third_party_flow_type_spec.rb
index ee14e0f2694f9a07f220d6b2e2d141403bf9d6aa..0bd518ae407c482392c31c24b81576001f6b0ea1 100644
--- a/ee/spec/graphql/types/ai/catalog/third_party_flow_type_spec.rb
+++ b/ee/spec/graphql/types/ai/catalog/third_party_flow_type_spec.rb
@@ -7,51 +7,27 @@
let_it_be(:project) { create(:project, maintainers: [current_user]) }
let_it_be(:item) { create(:ai_catalog_item, :third_party_flow, project: project, public: true) }
- specify { expect(described_class.graphql_name).to eq('AiCatalogThirdPartyFlowVersion') }
- specify { expect(described_class.interfaces).to include(::Types::Ai::Catalog::VersionInterface) }
-
- describe '#definition' do
- let(:query) do
- %(
+ let(:query) do
+ %{
query {
aiCatalogItem(id: "#{item.to_global_id}") {
latestVersion {
- ... on AiCatalogThirdPartyFlowVersion {
- definition
+ ... on #{described_class.graphql_name} {
+ definition
}
}
}
}
- )
- end
-
- let(:returned_definition) { subject.dig('data', 'aiCatalogItem', 'latestVersion', 'definition') }
-
- subject { GitlabSchema.execute(query, context: { current_user: }).as_json }
-
- context 'when yaml_definition is present' do
- before do
- item.latest_version.definition['yaml_definition'] = 'test'
- item.latest_version.save!
- end
+ }
+ end
- it 'returns the yaml_definition' do
- expect(returned_definition).to match('test')
- end
- end
+ let(:returned_definition) { subject.dig('data', 'aiCatalogItem', 'latestVersion', 'definition') }
- context 'when yaml_definition is not present' do
- let(:data) { graphql_data_at(:ai_catalog_item) }
+ subject { GitlabSchema.execute(query, context: { current_user: }).as_json }
- before do
- item.latest_version.definition.delete('yaml_definition')
- item.latest_version.definition['image'] = 'some_new_image:22'
- item.latest_version.save!
- end
+ specify { expect(described_class.graphql_name).to eq('AiCatalogThirdPartyFlowVersion') }
+ specify { expect(described_class.interfaces).to include(::Types::Ai::Catalog::VersionInterface) }
- it 'returns the definition generated by converting the definition to YAML' do
- expect(YAML.load(returned_definition)).to match(a_hash_including('image' => 'some_new_image:22'))
- end
- end
- end
+ it_behaves_like 'AI catalog version definition field'
+ it_behaves_like 'AI catalog version definition field with yaml_definition not present'
end
diff --git a/ee/spec/lib/ai/catalog/duo_workflow_payload_builder/experimental_spec.rb b/ee/spec/lib/ai/catalog/duo_workflow_payload_builder/experimental_spec.rb
index 0c56067490f3cc0b87e66b00dfe45f992a7d68bf..d955a755dcc5e778105402b5b2d8763fc6e7736d 100644
--- a/ee/spec/lib/ai/catalog/duo_workflow_payload_builder/experimental_spec.rb
+++ b/ee/spec/lib/ai/catalog/duo_workflow_payload_builder/experimental_spec.rb
@@ -46,7 +46,7 @@
end
let_it_be(:flow_version) do
- create(:ai_catalog_flow_version, item: flow_item, definition: flow_definition, version: '2.1.0')
+ create(:ai_catalog_agent_referenced_flow_version, item: flow_item, definition: flow_definition, version: '2.1.0')
end
subject(:builder) { described_class.new(flow_item) }
@@ -75,7 +75,7 @@
let_it_be(:empty_steps_definition) { { 'triggers' => [1], 'steps' => [] } }
let_it_be(:empty_steps_flow) { create(:ai_catalog_flow, project: project) }
let_it_be(:empty_steps_version) do
- create(:ai_catalog_flow_version, item: empty_steps_flow, definition: empty_steps_definition,
+ create(:ai_catalog_agent_referenced_flow_version, item: empty_steps_flow, definition: empty_steps_definition,
version: '2.1.0')
end
@@ -97,8 +97,12 @@
end
let_it_be(:single_agent_flow_version) do
- create(:ai_catalog_flow_version, item: single_agent_flow, definition: single_agent_flow_definition,
- version: '2.2.0')
+ create(
+ :ai_catalog_agent_referenced_flow_version,
+ item: single_agent_flow,
+ definition: single_agent_flow_definition,
+ version: '2.2.0'
+ )
end
let(:builder) { described_class.new(single_agent_flow, nil, params) }
@@ -170,7 +174,8 @@
'agent_id' => agent_item_2.id, 'current_version_id' => agent2_v1.id, 'pinned_version_prefix' => nil
}]
}
- create(:ai_catalog_flow_version, item: single_agent_flow, definition: definition, version: '2.3.0')
+ create(:ai_catalog_agent_referenced_flow_version, item: single_agent_flow, definition: definition,
+ version: '2.3.0')
end
context 'when no version is pinned' do
diff --git a/ee/spec/models/ai/catalog/flow_definition_spec.rb b/ee/spec/models/ai/catalog/flow_definition_spec.rb
index 09314d010c610d2507b9123245d34c336b990709..21d99cad68b1722d2fa003f24013f8482e2d5542 100644
--- a/ee/spec/models/ai/catalog/flow_definition_spec.rb
+++ b/ee/spec/models/ai/catalog/flow_definition_spec.rb
@@ -20,7 +20,7 @@
end
let_it_be(:flow_version) do
- create(:ai_catalog_flow_version, item: flow_item, definition: definition, version: '1.1.0')
+ create(:ai_catalog_agent_referenced_flow_version, item: flow_item, definition: definition, version: '1.1.0')
end
subject(:flow_definition) { described_class.new(flow_item, flow_version) }
diff --git a/ee/spec/models/ai/catalog/item_spec.rb b/ee/spec/models/ai/catalog/item_spec.rb
index 5b82b9659d1bcf8c83bb5f7038402a16349e58ca..e9e187f0c4ace3bb1e33269908eee0e26cd60f8f 100644
--- a/ee/spec/models/ai/catalog/item_spec.rb
+++ b/ee/spec/models/ai/catalog/item_spec.rb
@@ -133,7 +133,8 @@
end
let(:flow_version) do
- create(:ai_catalog_flow_version, item: flow_item, definition: flow_definition, version: '1.0.0')
+ create(:ai_catalog_agent_referenced_flow_version, item: flow_item, definition: flow_definition,
+ version: '1.0.0')
end
before do
diff --git a/ee/spec/models/ai/catalog/item_version_spec.rb b/ee/spec/models/ai/catalog/item_version_spec.rb
index c7a748414b3da43b7aff13ab56f7d178a6a68e22..edae90f05bd326b41bbc45391aed7e656444394e 100644
--- a/ee/spec/models/ai/catalog/item_version_spec.rb
+++ b/ee/spec/models/ai/catalog/item_version_spec.rb
@@ -5,8 +5,6 @@
RSpec.describe Ai::Catalog::ItemVersion, feature_category: :workflow_catalog do
subject(:version) { build_stubbed(:ai_catalog_item_version) }
- it_behaves_like 'Ai::Catalog::Concerns::FlowVersion'
-
describe 'associations' do
it { is_expected.to belong_to(:organization) }
it { is_expected.to belong_to(:item).required }
@@ -58,29 +56,26 @@
it { is_expected.not_to be_valid }
end
- describe 'steps.pinned_version_prefix' do
- [nil, '0', '0.1', '1', '12', '12.34', '123.456.789', '1.0.0'].each do |prefix|
- context "with step pinned_version_prefix #{prefix}" do
- before do
- version.definition['steps'] = [
- { 'agent_id' => 1, 'current_version_id' => 1, 'pinned_version_prefix' => prefix }
- ]
- end
-
- it { is_expected.to be_valid }
- end
+ context 'when definition has invalid types for required properties' do
+ before do
+ version.definition['version'] = 123
+ version.definition['environment'] = nil
+ version.definition['components'] = 'not an array'
+ version.definition['routers'] = true
+ version.definition['flow'] = 'not an object'
end
- ['1.2.3.4', '1.'].each do |prefix|
- context "with step pinned_version_prefix #{prefix}" do
- before do
- version.definition['steps'] = [
- { 'agent_id' => 1, 'current_version_id' => 1, 'pinned_version_prefix' => prefix }
- ]
- end
-
- it { is_expected.not_to be_valid }
- end
+ it "adds validation errors for each invalid property type and value" do
+ expect(version).not_to be_valid
+ expect(version.errors['definition']).to contain_exactly(
+ "value at `/version` is not a string",
+ "value at `/version` is not: v1",
+ "value at `/environment` is not a string",
+ "value at `/environment` is not one of: [\"ambient\"]",
+ "value at `/components` is not an array",
+ "value at `/routers` is not an array",
+ "value at `/flow` is not an object"
+ )
end
end
end
diff --git a/ee/spec/requests/api/graphql/ai/catalog/item_spec.rb b/ee/spec/requests/api/graphql/ai/catalog/item_spec.rb
index 0fac77035ec351a0f9f629de2016c9b0f5c68e5d..4e0540d9fe7e2daa34365bbc345ce80b98e4ae10 100644
--- a/ee/spec/requests/api/graphql/ai/catalog/item_spec.rb
+++ b/ee/spec/requests/api/graphql/ai/catalog/item_spec.rb
@@ -33,6 +33,7 @@
}
}
}
+ definition
}
... on AiCatalogAgentVersion {
systemPrompt
@@ -218,19 +219,12 @@
let_it_be(:flow) { create(:ai_catalog_flow, project: project, public: true) }
let(:params) { { id: flow.to_global_id } }
- it 'resolves flow steps agents' do
- create(:ai_catalog_flow_version, item: flow, definition: {
- triggers: [],
- steps: [
- { agent_id: catalog_item.id, current_version_id: catalog_item.latest_version.id, pinned_version_prefix: nil }
- ]
- })
-
+ it 'returns yaml definition' do
post_graphql(query, current_user: nil)
- expect(graphql_data_at(:ai_catalog_item, :latest_version, :steps, :nodes)).to include(a_hash_including(
- 'agent' => { 'name' => catalog_item.name }
- ))
+ expect(graphql_data_at(:ai_catalog_item, :latest_version, :definition)).to eq(
+ flow.latest_version.definition['yaml_definition']
+ )
end
end
diff --git a/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/create_spec.rb b/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/create_spec.rb
index 0b9606366ef7d0a87cbddaaf653102de90dfa0dc..ded49d0306bc7b9066030b1a574e742b04a2c4db 100644
--- a/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/create_spec.rb
+++ b/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/create_spec.rb
@@ -8,19 +8,33 @@
let_it_be(:maintainer) { create(:user) }
let_it_be(:project) { create(:project, maintainers: maintainer) }
- let_it_be(:agent) { create(:ai_catalog_agent, project: project) }
let(:current_user) { maintainer }
let(:mutation) { graphql_mutation(:ai_catalog_flow_create, params) }
let(:name) { 'Name' }
let(:description) { 'Description' }
+ let(:definition) do
+ <<~YAML
+ version: v1
+ environment: ambient
+ components:
+ - name: main_agent
+ type: AgentComponent
+ prompt_id: test_prompt
+ routers: []
+ flow:
+ entry_point: main_agent
+ YAML
+ end
+
let(:params) do
{
project_id: project.to_global_id,
name: name,
description: description,
public: true,
- steps: [{ agent_id: agent.to_global_id }]
+ steps: nil,
+ definition: definition
}
end
@@ -94,15 +108,10 @@
public: true
)
expect(item.latest_version).to have_attributes(
- schema_version: 1,
+ schema_version: ::Ai::Catalog::ItemVersion::FLOW_SCHEMA_VERSION,
version: '1.0.0',
release_date: nil,
- definition: {
- steps: [{
- agent_id: agent.id, current_version_id: agent.latest_version.id, pinned_version_prefix: nil
- }.stringify_keys],
- triggers: []
- }.stringify_keys
+ definition: YAML.safe_load(definition).merge('yaml_definition' => definition)
)
end
diff --git a/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/execute_spec.rb b/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/execute_spec.rb
index 25fa8a1489f6291904af28ec2d4d7f1e14cc50f7..8461d3af7728835550238b470da6de5c548d3a02 100644
--- a/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/execute_spec.rb
+++ b/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/execute_spec.rb
@@ -29,19 +29,9 @@
create(:ai_catalog_agent_version, item: agent_item_2, definition: agent_definition, version: '1.1.1')
end
- let_it_be(:flow_definition) do
- {
- 'triggers' => [1],
- 'steps' => [
- { 'agent_id' => agent_item_1.id, 'current_version_id' => agent1_v1.id, 'pinned_version_prefix' => nil },
- { 'agent_id' => agent_item_2.id, 'current_version_id' => agent2_v1.id, 'pinned_version_prefix' => nil }
- ]
- }
- end
-
let_it_be_with_reload(:flow_version) do
item_version = flow_item.latest_version
- item_version.update!(definition: flow_definition, release_date: 1.hour.ago)
+ item_version.update!(release_date: 1.hour.ago)
item_version
end
@@ -58,14 +48,7 @@
end
let(:json_config) do
- {
- 'version' => 'experimental',
- 'environment' => 'remote',
- 'components' => be_an(Array),
- 'routers' => be_an(Array),
- 'flow' => be_a(Hash),
- 'prompts' => be_an(Array)
- }
+ flow_version.definition.except('yaml_definition')
end
let(:params) do
@@ -179,8 +162,7 @@
it 'executes the latest version of the flow' do
latest_flow_version = create(
- :ai_catalog_flow_version, :released, version: '2.0.0', item: flow_item, definition: flow_definition
- )
+ :ai_catalog_flow_version, :released, version: '2.0.0', item: flow_item)
allow(::Ai::Catalog::Flows::ExecuteService).to receive(:new).and_call_original
execute
diff --git a/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/update_spec.rb b/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/update_spec.rb
index df42829be3d089b2b4753ca88ed58a9293cf5d2a..c46c7455ff6e778e0cc68f65e9609bd85061d230 100644
--- a/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/update_spec.rb
+++ b/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/update_spec.rb
@@ -13,10 +13,9 @@
create(:ai_catalog_flow_version, :released, version: '1.0.0', item: flow)
end
- let_it_be_with_reload(:latest_version) { create(:ai_catalog_flow_version, version: '1.1.0', item: flow) }
- let_it_be(:agent) { create(:ai_catalog_agent, project: project) }
- let_it_be(:agent_v1) { create(:ai_catalog_agent_version, version: '1.0.0', item: agent) }
- let_it_be(:agent_v1_1) { create(:ai_catalog_agent_version, version: '1.1.0', item: agent) }
+ let_it_be_with_reload(:latest_version) do
+ create(:ai_catalog_flow_version, version: '1.1.0', item: flow)
+ end
let(:current_user) { maintainer }
let(:mutation) do
@@ -34,16 +33,28 @@
end
let(:mutation_response) { graphql_data_at(:ai_catalog_flow_update) }
+ let(:definition) do
+ <<~YAML
+ version: v1
+ environment: ambient
+ components:
+ - name: updated_agent
+ type: AgentComponent
+ prompt_id: updated_prompt
+ routers: []
+ flow:
+ entry_point: updated_agent
+ YAML
+ end
+
let(:params) do
{
id: flow.to_global_id,
name: 'New name',
public: true,
description: 'New description',
- steps: [
- { agent_id: agent.to_global_id },
- { agent_id: agent.to_global_id, pinned_version_prefix: '1.0' }
- ],
+ steps: nil,
+ definition: definition,
version_bump: 'PATCH'
}
end
@@ -68,34 +79,6 @@
it_behaves_like 'an authorization failure'
end
- context 'when user does not have access to a step agent' do
- let_it_be(:agent) { create(:ai_catalog_agent) }
-
- it 'returns the service error message and item with original attributes' do
- original_name = flow.name
-
- execute
-
- expect(graphql_dig_at(mutation_response, :item, :name)).to eq(original_name)
- expect(graphql_dig_at(mutation_response, :errors)).to contain_exactly('You have insufficient permissions')
- end
- end
-
- context 'when step agent does not exist' do
- let(:params) do
- super().merge(steps: [{ agent_id: global_id_of(id: non_existing_record_id, model_name: 'Ai::Catalog::Item') }])
- end
-
- it 'returns the service error message and item with original attributes' do
- original_name = flow.name
-
- execute
-
- expect(graphql_dig_at(mutation_response, :item, :name)).to eq(original_name)
- expect(graphql_dig_at(mutation_response, :errors)).to contain_exactly('You have insufficient permissions')
- end
- end
-
context 'when global_ai_catalog feature flag is disabled' do
before do
stub_feature_flags(global_ai_catalog: false)
@@ -137,21 +120,12 @@
public: true
)
expect(latest_version.reload).to have_attributes(
- schema_version: 1,
+ schema_version: ::Ai::Catalog::ItemVersion::FLOW_SCHEMA_VERSION,
version: '1.0.1',
release_date: nil,
- definition: {
- steps: [
- {
- agent_id: agent.id, current_version_id: agent.latest_version.id, pinned_version_prefix: nil
- }.stringify_keys,
- {
- agent_id: agent.id, current_version_id: agent_v1.id, pinned_version_prefix: '1.0'
- }.stringify_keys
- ],
- triggers: [1]
- }.stringify_keys
+ definition: YAML.safe_load(definition).merge('yaml_definition' => definition)
)
+
expect(graphql_dig_at(mutation_response, :errors)).to be_empty
end
diff --git a/ee/spec/services/ai/catalog/execute_workflow_service_spec.rb b/ee/spec/services/ai/catalog/execute_workflow_service_spec.rb
index 1b9b0e63b7851693869ff7c525f6000239907be2..8f74fe745bf82353658c11a28ea0d17b8561ef60 100644
--- a/ee/spec/services/ai/catalog/execute_workflow_service_spec.rb
+++ b/ee/spec/services/ai/catalog/execute_workflow_service_spec.rb
@@ -178,6 +178,28 @@
)
end
+ it 'passes all necessary parameters to StartWorkflowService' do
+ workflow = nil
+ expect(::Ai::DuoWorkflows::StartWorkflowService).to receive(:new) do |args|
+ workflow = args[:workflow]
+ params = args[:params]
+
+ expect(params[:goal]).to eq(goal)
+ expect(params[:flow_config]).to eq(json_config)
+ expect(params[:flow_config_schema_version]).to eq('v1')
+ expect(params[:workflow_id]).to eq(workflow.id)
+ expect(params[:workflow_oauth_token]).to be_present
+ expect(params[:workflow_service_token]).to be_present
+ expect(params[:duo_agent_platform_feature_setting]).to be_present
+
+ start_workflow_service
+ end
+ allow(start_workflow_service).to receive(:execute)
+ .and_return(ServiceResponse.success(payload: { workload_id: 123 }))
+
+ service.execute
+ end
+
context 'when oauth token creation fails' do
before do
allow_next_instance_of(::Ai::DuoWorkflows::WorkflowContextGenerationService) do |service|
diff --git a/ee/spec/services/ai/catalog/flows/create_service_spec.rb b/ee/spec/services/ai/catalog/flows/create_service_spec.rb
index 91c9ca3ab7916aca6a896ba692841e1f0b1159f5..90bae6c4bc2783f629f99a0034dfb87b6942b3d2 100644
--- a/ee/spec/services/ai/catalog/flows/create_service_spec.rb
+++ b/ee/spec/services/ai/catalog/flows/create_service_spec.rb
@@ -7,20 +7,29 @@
let_it_be(:maintainer) { create(:user) }
let_it_be(:project) { create(:project, maintainers: maintainer) }
- let_it_be(:agent) { create(:ai_catalog_agent, project: project) }
- let_it_be(:v1_0) { create(:ai_catalog_agent_version, item: agent, version: '1.0.0') }
- let_it_be(:v1_1) { create(:ai_catalog_agent_version, item: agent, version: '1.1.0') }
let(:user) { maintainer }
+ let(:definition) do
+ <<~YAML
+ version: v1
+ environment: ambient
+ components:
+ - name: main_agent
+ type: AgentComponent
+ prompt_id: test_prompt
+ routers: []
+ flow:
+ entry_point: main_agent
+ YAML
+ end
+
let(:params) do
{
- name: 'Agent',
+ name: 'Flow',
description: 'Description',
public: true,
release: true,
- steps: [
- { agent: agent }
- ]
+ definition: definition
}
end
@@ -67,14 +76,7 @@
expect(item.latest_version).to have_attributes(
schema_version: ::Ai::Catalog::ItemVersion::FLOW_SCHEMA_VERSION,
version: '1.0.0',
- definition: {
- steps: [
- {
- agent_id: agent.id, current_version_id: v1_1.id, pinned_version_prefix: nil
- }.stringify_keys
- ],
- triggers: []
- }.stringify_keys
+ definition: YAML.safe_load(definition).merge('yaml_definition' => definition)
)
expect(item.latest_released_version).to eq(item.latest_version)
end
@@ -111,24 +113,7 @@
it_behaves_like 'an error response', ["Name can't be blank"]
end
- context 'when including a pinned_version_prefix' do
- let(:params) { super().merge(steps: [{ agent: agent, pinned_version_prefix: '1.0' }]) }
-
- it 'sets the correct current_version_id' do
- response
-
- item = Ai::Catalog::Item.last
- expect(item.versions.first.definition['steps'].first).to match a_hash_including(
- 'agent_id' => agent.id, 'current_version_id' => v1_0.id, 'pinned_version_prefix' => '1.0'
- )
- end
-
- context 'when the prefix is not valid' do
- let(:params) { super().merge(steps: [{ agent: agent, pinned_version_prefix: '999' }]) }
-
- it_behaves_like 'an error response', ['Step 1: Unable to resolve version with prefix 999']
- end
- end
+ it_behaves_like 'yaml definition create service behavior'
context 'when user is a developer' do
let(:user) { create(:user).tap { |user| project.add_developer(user) } }
@@ -144,32 +129,7 @@
it_behaves_like 'an error response', 'You have insufficient permissions'
end
- context 'when user does not have access to read one of the agents' do
- let_it_be(:agent) { create(:ai_catalog_agent, public: false) }
-
- it_behaves_like 'an error response', 'You have insufficient permissions'
- end
-
- context 'when user has access to read one of the agents, but it is private to another project' do
- let_it_be(:other_project) { create(:project, maintainers: maintainer) }
- let_it_be(:agent) { create(:ai_catalog_agent, public: false, project: other_project) }
-
- it_behaves_like 'an error response', 'Step 1: Agent is private to another project'
- end
-
- context 'when flow exceeds maximum steps' do
- before do
- stub_const("Ai::Catalog::Flows::FlowHelper::MAX_STEPS", 1)
- end
-
- let!(:params) do
- super().merge(steps: [{ agent: agent }, { agent: agent }])
- end
-
- it_behaves_like 'an error response', Ai::Catalog::Flows::FlowHelper::MAX_STEPS_ERROR
- end
-
- context 'when ai_catalog_third_party_flows feature flag is disabled' do
+ context 'when ai_catalog_flows feature flag is disabled' do
before do
stub_feature_flags(ai_catalog_flows: false)
end
@@ -178,49 +138,6 @@
end
end
- describe 'dependency tracking' do
- let_it_be(:agent2) { create(:ai_catalog_item, :agent, project:) }
- let_it_be(:agent3) { create(:ai_catalog_item, :agent, project:) }
-
- let(:params) do
- {
- name: 'Agent',
- description: 'Description',
- public: true,
- steps: [
- { agent: agent },
- { agent: agent2 },
- { agent: agent2 }
- ]
- }
- end
-
- it 'creates dependencies for each agent in the steps' do
- expect { response }.to change { Ai::Catalog::ItemVersionDependency.count }.by(2)
- flow_version = Ai::Catalog::ItemVersion.last
- expect(flow_version.dependencies.pluck(:dependency_id)).to contain_exactly(agent.id, agent2.id)
- end
-
- it 'does not call delete_no_longer_used_dependencies' do
- expect_next_instance_of(Ai::Catalog::ItemVersion) do |instance|
- expect(instance).not_to receive(:delete_no_longer_used_dependencies)
- end
-
- response
- end
-
- context 'when saving dependencies fails' do
- before do
- allow(Ai::Catalog::ItemVersionDependency).to receive(:bulk_insert!)
- .and_raise("Dummy error")
- end
-
- it 'does not create the item version' do
- expect { response }.to raise_error("Dummy error").and not_change { Ai::Catalog::Item.count }
- end
- end
- end
-
context 'when add_to_project_when_created is true' do
let(:params) { super().merge(add_to_project_when_created: true) }
diff --git a/ee/spec/services/ai/catalog/flows/execute_service_spec.rb b/ee/spec/services/ai/catalog/flows/execute_service_spec.rb
index 9cd9401863fad905c16ec77b4fca4ae693db50fe..d3bbf66270cdac0d264959ca1f76d7a7007024cf 100644
--- a/ee/spec/services/ai/catalog/flows/execute_service_spec.rb
+++ b/ee/spec/services/ai/catalog/flows/execute_service_spec.rb
@@ -8,40 +8,11 @@
let_it_be(:developer) { create(:user) }
let_it_be(:project) { create(:project, :repository, developers: developer) }
let_it_be(:flow) { create(:ai_catalog_flow, project: project) }
- let_it_be(:agent_item_1) { create(:ai_catalog_item, :agent, project: project) }
- let_it_be(:agent_item_2) { create(:ai_catalog_item, :agent, project: project) }
- let_it_be(:tool_ids) { [1, 2, 5] } # 1 => "gitlab_blob_search" 2 => 'ci_linter', 5 => 'create_epic'
let_it_be(:user_prompt) { nil }
- let_it_be(:agent_definition) do
- {
- 'system_prompt' => 'Talk like a pirate!',
- 'user_prompt' => 'What is a leap year?',
- 'tools' => tool_ids
- }
- end
-
- let_it_be(:agent1) do
- create(:ai_catalog_agent_version, item: agent_item_1, definition: agent_definition, version: '1.1.0')
- end
-
- let_it_be(:agent2) do
- create(:ai_catalog_agent_version, item: agent_item_2, definition: agent_definition, version: '1.1.1')
- end
-
- let_it_be(:flow_definition) do
- {
- 'triggers' => [1],
- 'steps' => [
- { 'agent_id' => agent_item_1.id, 'current_version_id' => agent1.id, 'pinned_version_prefix' => nil },
- { 'agent_id' => agent_item_2.id, 'current_version_id' => agent2.id, 'pinned_version_prefix' => nil }
- ]
- }
- end
-
let_it_be_with_reload(:flow_version) do
item_version = flow.latest_version
- item_version.update!(definition: flow_definition, release_date: 1.hour.ago)
+ item_version.update!(release_date: 1.hour.ago)
item_version
end
@@ -55,15 +26,8 @@
}
end
- let(:json_config) do
- {
- 'version' => 'experimental',
- 'environment' => 'remote',
- 'components' => be_an(Array),
- 'routers' => be_an(Array),
- 'flow' => be_a(Hash),
- 'prompts' => be_an(Array)
- }
+ let(:expected_flow_config) do
+ flow_version.definition.except('yaml_definition')
end
let(:current_user) { developer }
@@ -136,15 +100,6 @@
it_behaves_like 'returns error response', 'Flow version must belong to the flow'
end
- context 'when flow_version has no steps' do
- before do
- flow_version.definition = { steps: [], triggers: [1] }
- flow_version.save!(validate: false) # Cannot update definition of already released version
- end
-
- it_behaves_like 'returns error response', 'Flow version must have steps'
- end
-
context 'when execute_workflow is false' do
let(:service_params) { super().merge({ execute_workflow: false }) }
@@ -159,7 +114,7 @@
parsed_yaml = YAML.safe_load(result.payload[:flow_config], aliases: true)
expect(result).to be_success
- expect(parsed_yaml).to include(json_config)
+ expect(parsed_yaml).to eq(expected_flow_config)
end
end
@@ -189,13 +144,21 @@
end
it 'provides a success response containing workflow and flow details' do
- expect(::Ai::Catalog::ExecuteWorkflowService).to receive(:new).and_call_original
+ expect(::Ai::Catalog::ExecuteWorkflowService).to receive(:new).with(
+ current_user,
+ hash_including(
+ json_config: be_a(Hash),
+ container: project,
+ goal: flow.description,
+ item_version: flow_version
+ )
+ ).and_call_original
result = execute
parsed_yaml = YAML.safe_load(result.payload[:flow_config], aliases: true)
expect(result).to be_success
- expect(parsed_yaml).to include(json_config)
+ expect(parsed_yaml).to eq(expected_flow_config)
expect(result.payload[:workflow]).to eq(Ai::DuoWorkflows::Workflow.last)
expect(result.payload[:workload_id]).to eq(Ci::Workloads::Workload.last.id)
end
@@ -263,12 +226,22 @@
context 'when user_prompt is specified' do
let(:service_params) { super().merge({ user_prompt: "test input" }) }
- it 'does not call execute_workflow_service' do
+ it 'passes user_prompt as goal to ExecuteWorkflowService' do
+ expect(::Ai::Catalog::ExecuteWorkflowService).to receive(:new).with(
+ current_user,
+ hash_including(
+ json_config: be_a(Hash),
+ container: project,
+ goal: "test input",
+ item_version: flow_version
+ )
+ ).and_call_original
+
result = execute
parsed_yaml = YAML.safe_load(result.payload[:flow_config], aliases: true)
expect(result).to be_success
- expect(parsed_yaml['prompts'][0]['prompt_template']['user']).to eq('test input')
+ expect(parsed_yaml).to eq(expected_flow_config)
end
end
end
diff --git a/ee/spec/services/ai/catalog/flows/update_service_spec.rb b/ee/spec/services/ai/catalog/flows/update_service_spec.rb
index 7f5bec59a6f7e806a7893720261fb0201fc5c214..9db990b7ea09900bc80ee9b7b4e21bb126595f99 100644
--- a/ee/spec/services/ai/catalog/flows/update_service_spec.rb
+++ b/ee/spec/services/ai/catalog/flows/update_service_spec.rb
@@ -13,9 +13,20 @@
end
let_it_be_with_reload(:latest_version) { create(:ai_catalog_flow_version, version: '1.1.0', item: item) }
- let_it_be(:agent) { create(:ai_catalog_agent, project: project) }
- let_it_be(:agent_v1_0) { create(:ai_catalog_agent_version, item: agent, version: '1.0.0') }
- let_it_be(:agent_v1_1) { create(:ai_catalog_agent_version, item: agent, version: '1.1.0') }
+
+ let(:definition) do
+ <<~YAML
+ version: v1
+ environment: ambient
+ components:
+ - name: updated_agent
+ type: AgentComponent
+ prompt_id: updated_prompt
+ routers: []
+ flow:
+ entry_point: updated_agent
+ YAML
+ end
let(:params) do
{
@@ -24,7 +35,7 @@
description: 'New description',
public: true,
release: true,
- steps: [{ agent: agent }]
+ definition: definition
}
end
@@ -37,16 +48,7 @@
it_behaves_like Ai::Catalog::Items::BaseUpdateService do
let(:item_schema_version) { Ai::Catalog::ItemVersion::FLOW_SCHEMA_VERSION }
let(:expected_updated_definition) do
- {
- steps: [
- {
- agent_id: agent.id,
- current_version_id: agent_v1_1.id,
- pinned_version_prefix: nil
- }.stringify_keys
- ],
- triggers: [1]
- }
+ YAML.safe_load(definition).merge('yaml_definition' => definition)
end
context 'when user has permissions' do
@@ -54,40 +56,17 @@
project.add_maintainer(user)
end
- context 'when including a pinned_version_prefix' do
- let(:params) { super().merge(steps: [{ agent: agent, pinned_version_prefix: '1.0' }]) }
-
- it 'sets the correct current_version_id' do
+ context 'when definition is provided and valid' do
+ it 'updates attributes correctly' do
execute_service
- expect(latest_version.definition['steps'].first).to match a_hash_including(
- 'agent_id' => agent.id, 'current_version_id' => agent_v1_0.id, 'pinned_version_prefix' => '1.0'
+ expect(latest_version.reload.definition).to eq(expected_updated_definition)
+ expect(item.reload).to have_attributes(
+ name: 'New name',
+ description: 'New description',
+ public: true
)
end
-
- context 'when the prefix is not valid' do
- let(:params) { super().merge(steps: [{ agent: agent, pinned_version_prefix: '2.2' }]) }
-
- it_behaves_like 'an error response', 'Step 1: Unable to resolve version with prefix 2.2'
- end
- end
-
- context 'when flow exceeds maximum steps' do
- before do
- stub_const("Ai::Catalog::Flows::FlowHelper::MAX_STEPS", 1)
- end
-
- let!(:params) do
- super().merge(steps: [{ agent: agent }, { agent: agent }])
- end
-
- it_behaves_like 'an error response', Ai::Catalog::Flows::FlowHelper::MAX_STEPS_ERROR
- end
-
- context 'when user does not have access to read one of the agents' do
- let_it_be(:agent) { create(:ai_catalog_agent, public: false) }
-
- it_behaves_like 'an error response', 'You have insufficient permissions'
end
context 'when flow is not a flow' do
@@ -98,103 +77,7 @@
it_behaves_like 'an error response', 'Flow not found'
end
- context 'when user has access to read one of the agents, but it is private to another project' do
- let_it_be(:other_project) { create(:project, maintainers: user) }
- let_it_be(:agent) { create(:ai_catalog_agent, public: false, project: other_project) }
-
- it_behaves_like 'an error response', 'Step 1: Agent is private to another project'
- end
-
- describe 'dependency tracking' do
- let_it_be(:agent2) { create(:ai_catalog_item, :agent, project:) }
- let_it_be(:agent3) { create(:ai_catalog_item, :agent, project:) }
- let_it_be(:agent4) { create(:ai_catalog_item, :agent, project:) }
-
- let_it_be(:existing_dependency) do
- create(
- :ai_catalog_item_version_dependency, ai_catalog_item_version: item.latest_version, dependency_id: agent.id
- )
- end
-
- let_it_be(:existing_dependency_no_longer_needed) do
- create(
- :ai_catalog_item_version_dependency, ai_catalog_item_version: item.latest_version, dependency_id: agent2.id
- )
- end
-
- let(:params) do
- {
- item: item,
- name: 'New name',
- description: 'New description',
- public: true,
- release: true,
- steps: [
- { agent: agent3 },
- { agent: agent }
- ]
- }
- end
-
- it 'updates the dependencies' do
- execute_service
-
- expect(latest_version.reload.dependencies.pluck(:dependency_id)).to contain_exactly(agent3.id, agent.id)
- end
-
- context 'when there are other item versions with dependencies' do
- let_it_be(:other_latest_version_dependency) { create(:ai_catalog_item_version_dependency) }
-
- it 'does not affect dependencies from other records' do
- expect { execute_service }
- .not_to change { Ai::Catalog::ItemVersionDependency.find(other_latest_version_dependency.id) }
- end
- end
-
- context 'when saving dependencies fails' do
- before do
- allow(Ai::Catalog::ItemVersionDependency).to receive(:bulk_insert!)
- .and_raise("Dummy error")
- end
-
- it 'does not update the item' do
- expect { execute_service }
- .to raise_error("Dummy error").and not_change { item.reload.attributes }
- end
- end
-
- context 'when the flow version is not changing' do
- let(:params) do
- {
- item: item,
- description: 'New description'
- }
- end
-
- it 'does not update the dependencies' do
- expect(Ai::Catalog::ItemVersionDependency).not_to receive(:bulk_insert!)
-
- execute_service
- end
- end
-
- it 'does not cause N+1 queries for each dependency created' do
- # Warmup
- params = { item: item, steps: [{ agent: agent4 }] }
- service = described_class.new(project: project, current_user: user, params: params)
- service.execute
-
- params = { item: item, steps: [{ agent: agent }] }
- service = described_class.new(project: project, current_user: user, params: params)
- control = ActiveRecord::QueryRecorder.new(skip_cached: false) { service.execute }
-
- params = { item: item, steps: [{ agent: agent2 }, { agent: agent3 }] }
- service = described_class.new(project: project, current_user: user, params: params)
-
- # Ai::Catalog::Flows::FlowHelper#allowed? queries for each agent to check permissions.
- expect { service.execute }.not_to exceed_query_limit(control).with_threshold(1)
- end
- end
+ it_behaves_like 'yaml definition update service behavior'
end
end
end
diff --git a/ee/spec/services/ai/catalog/third_party_flows/create_service_spec.rb b/ee/spec/services/ai/catalog/third_party_flows/create_service_spec.rb
index 5c310c3c663afbceac2b473288b70633a45805de..82e586109435024c8bddce716ee56b4b31426edf 100755
--- a/ee/spec/services/ai/catalog/third_party_flows/create_service_spec.rb
+++ b/ee/spec/services/ai/catalog/third_party_flows/create_service_spec.rb
@@ -9,13 +9,8 @@
let_it_be(:project) { create(:project, maintainers: maintainer) }
let(:user) { maintainer }
- let(:params) do
- {
- name: 'Agent',
- description: 'Description',
- public: true,
- release: true,
- definition: <<-YAML
+ let(:definition) do
+ <<~YAML
injectGatewayToken: true
image: example/image:latest
commands:
@@ -23,7 +18,16 @@
variables:
- VAL1
- VAL2
- YAML
+ YAML
+ end
+
+ let(:params) do
+ {
+ name: 'Agent',
+ description: 'Description',
+ public: true,
+ release: true,
+ definition: definition
}
end
@@ -71,13 +75,7 @@
schema_version: 1,
version: '1.0.0',
release_date: Time.zone.now,
- definition: {
- injectGatewayToken: true,
- image: 'example/image:latest',
- commands: ['/bin/bash'],
- variables: %w[VAL1 VAL2],
- yaml_definition: params[:definition]
- }.stringify_keys
+ definition: YAML.safe_load(definition).merge('yaml_definition' => definition)
)
end
@@ -112,13 +110,7 @@
it_behaves_like 'an error response', ["Name can't be blank"]
end
- context 'when the provided YAML is not structured correctly' do
- before do
- params[:definition] = '"invalid: yaml data'
- end
-
- it_behaves_like 'an error response', ["definition does not have a valid YAML syntax"]
- end
+ it_behaves_like 'yaml definition create service behavior', 'ThirdPartyFlow'
context 'when user is a developer' do
let(:user) { create(:user).tap { |user| project.add_developer(user) } }
diff --git a/ee/spec/services/ai/catalog/third_party_flows/update_service_spec.rb b/ee/spec/services/ai/catalog/third_party_flows/update_service_spec.rb
index 08c6247cb415456d21845ee69ef7fa674bbb756b..60deb5cd2add5ef40d6b4e6b5edc0206625e0180 100644
--- a/ee/spec/services/ai/catalog/third_party_flows/update_service_spec.rb
+++ b/ee/spec/services/ai/catalog/third_party_flows/update_service_spec.rb
@@ -73,18 +73,7 @@
it_behaves_like 'an error response', 'You have insufficient permissions'
end
- context 'when YAML is not valid' do
- let(:params) { super().merge(definition: "this: is\n - not\n yaml: true") }
-
- it 'handles invalid yaml' do
- response = service.execute
-
- expect(response).to be_error
-
- expect(response.message)
- .to contain_exactly("ThirdPartyFlow definition does not have a valid YAML syntax")
- end
- end
+ it_behaves_like 'validates yaml definition syntax', 'ThirdPartyFlow'
end
end
end
diff --git a/ee/spec/services/ai/catalog/wrapped_agent_flow_builder_spec.rb b/ee/spec/services/ai/catalog/wrapped_agent_flow_builder_spec.rb
index 20f905901edf111cbdd41c9f2ee1e7abe1541401..ecc848dfdc9b60fdf80dca95c9226b0fb64bf34e 100644
--- a/ee/spec/services/ai/catalog/wrapped_agent_flow_builder_spec.rb
+++ b/ee/spec/services/ai/catalog/wrapped_agent_flow_builder_spec.rb
@@ -84,7 +84,7 @@
flow_version = flow.versions.last
expect(flow_version).to be_a(::Ai::Catalog::ItemVersion)
- expect(flow_version.schema_version).to eq(::Ai::Catalog::ItemVersion::FLOW_SCHEMA_VERSION)
+ expect(flow_version.schema_version).to eq(::Ai::Catalog::ItemVersion::AGENT_REFERENCED_FLOW_SCHEMA_VERSION)
expect(flow_version.version).to eq(described_class::GENERATED_FLOW_VERSION)
end
diff --git a/ee/spec/services/ai/flow_triggers/run_service_spec.rb b/ee/spec/services/ai/flow_triggers/run_service_spec.rb
index 5fe1300124242a2c25e1c0543afe4451d3861f56..071e63c3968c7e000fc355ef11f9732dad7ac794 100644
--- a/ee/spec/services/ai/flow_triggers/run_service_spec.rb
+++ b/ee/spec/services/ai/flow_triggers/run_service_spec.rb
@@ -887,4 +887,36 @@ def expected_gitlab_hostname
end
end
end
+
+ describe '#catalog_item_user_prompt' do
+ let(:input) { '123' }
+ let(:serialized_resource) { '{"id":123,"type":"Issue"}' }
+
+ before do
+ allow(service).to receive(:serialized_resource).and_return(serialized_resource)
+ end
+
+ context 'when event type is mention' do
+ let(:input) { '@issue_planner can you plan this issue?' }
+
+ it 'returns input with context' do
+ result = service.send(:catalog_item_user_prompt, input, :mention)
+ expect(result).to eq("Input: #{input}\nContext: #{serialized_resource}")
+ end
+ end
+
+ context 'when event type is assign' do
+ it 'returns only user input' do
+ result = service.send(:catalog_item_user_prompt, input, :assign)
+ expect(result).to eq(input)
+ end
+ end
+
+ context 'when event type is assign_reviewer' do
+ it 'returns only user input' do
+ result = service.send(:catalog_item_user_prompt, input, :assign_reviewer)
+ expect(result).to eq(input)
+ end
+ end
+ end
end
diff --git a/ee/spec/services/ee/issues/create_service_spec.rb b/ee/spec/services/ee/issues/create_service_spec.rb
index 7cff7e9618ebce8a592046af3bae0224560480c8..e130b4f3462ae3d8c5e67b342dd7ddbc59e2b021 100644
--- a/ee/spec/services/ee/issues/create_service_spec.rb
+++ b/ee/spec/services/ee/issues/create_service_spec.rb
@@ -637,8 +637,8 @@
let(:params) { { title: 'New issue', assignee_ids: [service_account_1.id, service_account_2.id] } }
it 'triggers all matching flow trigger services' do
- expect(run_service_1).to receive(:execute).with({ input: "", event: :assign })
- expect(run_service_2).to receive(:execute).with({ input: "", event: :assign })
+ expect(run_service_1).to receive(:execute).with({ input: an_instance_of(String), event: :assign })
+ expect(run_service_2).to receive(:execute).with({ input: an_instance_of(String), event: :assign })
expect(::Ai::FlowTriggers::RunService).to receive(:new)
.with(project: project, current_user: user, resource: an_instance_of(Issue), flow_trigger: flow_trigger_1)
@@ -655,7 +655,7 @@
let(:params) { { title: 'New issue', assignee_ids: [service_account_1.id, service_account_3.id] } }
it 'only triggers assign event flow triggers' do
- expect(run_service_1).to receive(:execute).with({ input: "", event: :assign })
+ expect(run_service_1).to receive(:execute).with({ input: an_instance_of(String), event: :assign })
expect(::Ai::FlowTriggers::RunService).to receive(:new)
.with(project: project, current_user: user, resource: an_instance_of(Issue), flow_trigger: flow_trigger_1)
.and_return(run_service_1)
@@ -706,8 +706,8 @@
let(:params) { { title: 'New issue', description: "/assign @#{service_account_1.username} @#{service_account_2.username}" } }
it 'triggers flow trigger services for assigned users' do
- expect(run_service_1).to receive(:execute).with({ input: "", event: :assign })
- expect(run_service_2).to receive(:execute).with({ input: "", event: :assign })
+ expect(run_service_1).to receive(:execute).with({ input: an_instance_of(String), event: :assign })
+ expect(run_service_2).to receive(:execute).with({ input: an_instance_of(String), event: :assign })
expect(::Ai::FlowTriggers::RunService).to receive(:new)
.with(project: project, current_user: user, resource: an_instance_of(Issue), flow_trigger: flow_trigger_1)
diff --git a/ee/spec/services/ee/issues/update_service_spec.rb b/ee/spec/services/ee/issues/update_service_spec.rb
index 04fbf9194a49e8dcd1a7e41c7df3f75a4429d0fd..9850cfc68d87ce14ed7cc08e674134d50bdd55c5 100644
--- a/ee/spec/services/ee/issues/update_service_spec.rb
+++ b/ee/spec/services/ee/issues/update_service_spec.rb
@@ -1096,8 +1096,8 @@ def update_issue(opts)
context 'when assigning multiple users with flow triggers' do
it 'triggers all matching flow trigger services' do
- expect(run_service_1).to receive(:execute).with({ input: '', event: :assign })
- expect(run_service_2).to receive(:execute).with({ input: '', event: :assign })
+ expect(run_service_1).to receive(:execute).with({ input: issue.iid.to_s, event: :assign })
+ expect(run_service_2).to receive(:execute).with({ input: issue.iid.to_s, event: :assign })
expect(::Ai::FlowTriggers::RunService).to receive(:new)
.with(project: project, current_user: user, resource: issue, flow_trigger: flow_trigger_1)
@@ -1112,7 +1112,7 @@ def update_issue(opts)
context 'when assigning users with mixed trigger types' do
it 'only triggers assign event flow triggers' do
- expect(run_service_1).to receive(:execute).with({ input: '', event: :assign })
+ expect(run_service_1).to receive(:execute).with({ input: issue.iid.to_s, event: :assign })
expect(::Ai::FlowTriggers::RunService).to receive(:new)
.with(project: project, current_user: user, resource: issue, flow_trigger: flow_trigger_1)
.and_return(run_service_1)
diff --git a/ee/spec/services/ee/merge_requests/handle_assignees_change_service_spec.rb b/ee/spec/services/ee/merge_requests/handle_assignees_change_service_spec.rb
index a851e2e13167f611ddb526050d4754cac5a61ff9..9c541981cb7e28d5d82a954f2adef3291ee30a29 100644
--- a/ee/spec/services/ee/merge_requests/handle_assignees_change_service_spec.rb
+++ b/ee/spec/services/ee/merge_requests/handle_assignees_change_service_spec.rb
@@ -47,7 +47,7 @@ def execute
run_service = instance_double(::Ai::FlowTriggers::RunService)
- expect(run_service).to receive(:execute).with({ input: '', event: :assign })
+ expect(run_service).to receive(:execute).with({ input: merge_request.iid.to_s, event: :assign })
expect(::Ai::FlowTriggers::RunService).to receive(:new)
.with(project: project, current_user: user, resource: merge_request, flow_trigger: flow_trigger)
.and_return(run_service)
diff --git a/ee/spec/services/ee/merge_requests/update_service_spec.rb b/ee/spec/services/ee/merge_requests/update_service_spec.rb
index 033565a8845da7d42b31710b8b3ac0d4f5bb03af..3d857151bc07545dd258ec139a99ad94ec3518aa 100644
--- a/ee/spec/services/ee/merge_requests/update_service_spec.rb
+++ b/ee/spec/services/ee/merge_requests/update_service_spec.rb
@@ -954,7 +954,7 @@ def update_merge_request(opts)
context 'when requesting review from this account', :sidekiq_inline do
it 'triggers the AI flow' do
- expect(run_service).to receive(:execute).with({ input: "", event: :assign })
+ expect(run_service).to receive(:execute).with({ input: merge_request.iid.to_s, event: :assign })
expect(::Ai::FlowTriggers::RunService).to receive(:new)
.with(
project: project, current_user: current_user,
@@ -967,7 +967,7 @@ def update_merge_request(opts)
context 'when this account is assigned' do
it 'triggers the AI flow' do
- expect(run_service).to receive(:execute).with({ input: "", event: :assign_reviewer })
+ expect(run_service).to receive(:execute).with({ input: merge_request.iid.to_s, event: :assign_reviewer })
expect(::Ai::FlowTriggers::RunService).to receive(:new)
.with(
project: project, current_user: current_user,
diff --git a/ee/spec/services/merge_requests/request_review_service_spec.rb b/ee/spec/services/merge_requests/request_review_service_spec.rb
index 3f4632e612c8c88976ac1de696f104a09d0a54df..d56701537c042af7822c01935e9da55e65f27b30 100644
--- a/ee/spec/services/merge_requests/request_review_service_spec.rb
+++ b/ee/spec/services/merge_requests/request_review_service_spec.rb
@@ -98,7 +98,7 @@
it 'triggers the AI flow' do
merge_request.reviewers << service_account
- expect(run_service).to receive(:execute).with({ input: "", event: :assign_reviewer })
+ expect(run_service).to receive(:execute).with({ input: merge_request.iid.to_s, event: :assign_reviewer })
expect(::Ai::FlowTriggers::RunService).to receive(:new)
.with(
project: merge_request.project, current_user: current_user,
diff --git a/ee/spec/support/shared_examples/graphql/types/ai/catalog/definition_field_shared_examples.rb b/ee/spec/support/shared_examples/graphql/types/ai/catalog/definition_field_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5575abcda21a700d540c079ae80e6a269393273a
--- /dev/null
+++ b/ee/spec/support/shared_examples/graphql/types/ai/catalog/definition_field_shared_examples.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'AI catalog version definition field' do
+ context 'when yaml_definition is present' do
+ before do
+ item.latest_version.definition['yaml_definition'] = 'test'
+ item.latest_version.save!
+ end
+
+ it 'returns the yaml_definition' do
+ expect(returned_definition).to match('test')
+ end
+ end
+end
+
+RSpec.shared_examples 'AI catalog version definition field with yaml_definition not present' do
+ context 'when yaml_definition is not present' do
+ before do
+ item.latest_version.definition.delete('yaml_definition')
+ item.latest_version.save!
+ end
+
+ it 'returns the definition generated by converting the definition to YAML' do
+ expect(YAML.load(returned_definition)).to match(item.latest_version.definition)
+ end
+ end
+end
diff --git a/ee/spec/support/shared_examples/services/ai/catalog/yaml_definition_shared_examples.rb b/ee/spec/support/shared_examples/services/ai/catalog/yaml_definition_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8f416b22a540c5f94f50f614f6822f194869e429
--- /dev/null
+++ b/ee/spec/support/shared_examples/services/ai/catalog/yaml_definition_shared_examples.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'validates yaml definition syntax' do |item_type = 'Flow'|
+ context 'when the provided YAML is not structured correctly' do
+ before do
+ params[:definition] = '"invalid: yaml data'
+ end
+
+ it_behaves_like 'an error response', ["#{item_type} definition does not have a valid YAML syntax"]
+ end
+end
+
+RSpec.shared_examples 'handles missing yaml definition' do
+ context 'when definition is not provided' do
+ let(:params) { super().except(:definition) }
+
+ it 'does not update the definition' do
+ existing_definition = latest_version.definition
+
+ execute_service
+
+ expect(latest_version.reload.definition).to eq(existing_definition)
+ end
+ end
+end
+
+RSpec.shared_examples 'yaml definition create service behavior' do |item_type = 'Flow'|
+ it_behaves_like 'validates yaml definition syntax', item_type
+
+ context 'when definition is provided and valid' do
+ it 'creates item version with parsed YAML definition' do
+ expect { response }.to change { Ai::Catalog::ItemVersion.count }.by(1)
+
+ item = Ai::Catalog::Item.last
+ expected_definition = YAML.safe_load(definition).merge('yaml_definition' => definition)
+ expect(item.latest_version.definition).to eq(expected_definition)
+ end
+ end
+end
+
+RSpec.shared_examples 'yaml definition update service behavior' do |item_type = 'Flow'|
+ it_behaves_like 'validates yaml definition syntax', item_type
+ it_behaves_like 'handles missing yaml definition'
+
+ context 'when definition is provided and valid' do
+ it 'updates the definition correctly' do
+ expected_definition = YAML.safe_load(definition).merge('yaml_definition' => definition)
+
+ expect { execute_service }
+ .to change { latest_version.reload.definition }
+ .to(expected_definition)
+ end
+ end
+end