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