From 2485dbfa5fff83dd88e87d0cfd3f0b39bdf923b4 Mon Sep 17 00:00:00 2001 From: Imjaydip Date: Thu, 30 Oct 2025 18:35:51 +0530 Subject: [PATCH 1/6] Add support for flow registry v1 version-based definitions for DAP custom flows --- .../json_schemas/ai_catalog/flow_v1.json | 509 +++++++++++++++++- doc/api/graphql/reference/_index.md | 7 +- .../mutations/ai/catalog/flow/create.rb | 12 +- .../mutations/ai/catalog/flow/update.rb | 13 +- .../types/ai/catalog/flow_version_type.rb | 10 +- ee/app/models/ai/catalog/item_version.rb | 2 - .../ai/catalog/execute_workflow_service.rb | 7 +- .../ai/catalog/flows/create_service.rb | 16 +- .../ai/catalog/flows/execute_service.rb | 10 +- .../ai/catalog/flows/update_service.rb | 27 +- ee/spec/factories/ai/catalog/item_versions.rb | 30 +- .../mutations/ai/catalog/flow/create_spec.rb | 1 + .../mutations/ai/catalog/flow/update_spec.rb | 1 + .../ai/catalog/flow_version_type_spec.rb | 57 +- .../models/ai/catalog/item_version_spec.rb | 41 +- .../mutations/ai/catalog/flow/create_spec.rb | 25 +- .../mutations/ai/catalog/flow/update_spec.rb | 68 +-- .../catalog/execute_workflow_service_spec.rb | 22 + .../ai/catalog/flows/create_service_spec.rb | 122 +---- .../ai/catalog/flows/execute_service_spec.rb | 79 +-- .../ai/catalog/flows/update_service_spec.rb | 163 ++---- 21 files changed, 774 insertions(+), 448 deletions(-) diff --git a/app/validators/json_schemas/ai_catalog/flow_v1.json b/app/validators/json_schemas/ai_catalog/flow_v1.json index 2370e2a1c71ddf..c343fed2bc9eaa 100644 --- a/app/validators/json_schemas/ai_catalog/flow_v1.json +++ b/app/validators/json_schemas/ai_catalog/flow_v1.json @@ -1,45 +1,516 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://gitlab.com/gitlab-org/modelops/applied-ml/code-suggestions/ai-assist/schemas/flow_registry_v1_schema.json", + "title": "Flow Registry v1 Configuration Schema", + "description": "JSON Schema for validating Flow Registry v1 YAML configuration files", "type": "object", "required": [ - "triggers", - "steps" + "version", + "environment", + "components", + "routers", + "flow" ], "additionalProperties": false, "properties": { - "triggers": { + "version": { + "type": "string", + "const": "v1", + "description": "Framework version - must be 'v1' for current stable version" + }, + "environment": { + "type": "string", + "enum": [ + "chat", + "chat-partial", + "ambient" + ], + "description": "Flow environment declaring expected level of interaction between human and AI agent" + }, + "components": { "type": "array", - "items": {} + "minItems": 1, + "description": "List of components that make up the flow", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/AgentComponent" + }, + { + "$ref": "#/definitions/DeterministicStepComponent" + }, + { + "$ref": "#/definitions/OneOffComponent" + } + ] + } }, - "steps": { + "routers": { "type": "array", + "description": "Define how components connect to each other", "items": { - "type": "object", - "required": [ - "agent_id", - "current_version_id", - "pinned_version_prefix" - ], + "$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_]+$" + } + }, + "additionalProperties": true + }, + "prompts": { + "type": "array", + "description": "List of inline prompt templates for flow components to use", + "items": { + "$ref": "#/definitions/LocalPrompt" + } + }, + "yaml_definition": { + "type": "string" + } + }, + "allOf": [ + { + "if": { + "properties": { + "environment": { + "const": "chat-partial" + } + } + }, + "then": { "properties": { - "agent_id": { - "type": "integer" + "components": { + "maxItems": 1, + "items": { + "properties": { + "type": { + "const": "AgentComponent" + } + } + } }, - "current_version_id": { - "type": "integer" + "routers": { + "maxItems": 0 }, - "pinned_version_prefix": { + "flow": { + "maxProperties": 0 + } + } + } + } + ], + "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+.*$", + "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": "null" + "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", - "pattern": "^\\d+(?:\\.\\d+){0,2}$" + "description": "Simple input reference. Examples: 'context:goal'" + }, + { + "$ref": "#/definitions/InputMapping" } ] } }, - "additionalProperties": false + "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+.*$", + "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" + } } } } diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 0f4176ec16c907..f3ad828ecedecd 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 65e7e098d554ed..6935d2838d9412 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 2f1ee4c78bc8cc..d42d868eadfc2c 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 1781652c6ccd2e..e4f4010272a97a 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'] || object.definition.to_yaml + 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 6379ac6219f179..d1784287658535 100644 --- a/ee/app/models/ai/catalog/item_version.rb +++ b/ee/app/models/ai/catalog/item_version.rb @@ -3,8 +3,6 @@ module Ai module Catalog class ItemVersion < ApplicationRecord - include ::Ai::Catalog::Concerns::FlowVersion - AGENT_SCHEMA_VERSION = 1 FLOW_SCHEMA_VERSION = 1 THIRD_PARTY_FLOW_SCHEMA_VERSION = 1 diff --git a/ee/app/services/ai/catalog/execute_workflow_service.rb b/ee/app/services/ai/catalog/execute_workflow_service.rb index 799bbdb4c149db..30df1e3c013740 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 fe73a2c3e6aadd..5336d282ef4a03 100644 --- a/ee/app/services/ai/catalog/flows/create_service.rb +++ b/ee/app/services/ai/catalog/flows/create_service.rb @@ -8,9 +8,6 @@ class CreateService < Ai::Catalog::BaseService 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 +15,17 @@ def execute organization_id: project.organization_id, 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 + 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 +59,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 7b1a46ea994998..d522f01f188a9b 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 c64813be88ff8e..9a84e258c30bc1 100644 --- a/ee/app/services/ai/catalog/flows/update_service.rb +++ b/ee/app/services/ai/catalog/flows/update_service.rb @@ -12,32 +12,41 @@ class UpdateService < Items::BaseUpdateService 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? + error('Flow definition does not have a valid YAML syntax') unless valid_definition? end override :build_version_params - def build_version_params(latest_version) - return {} unless params.key?(:steps) + def build_version_params(_latest_version) + return {} unless params.key?(:definition) { - definition: latest_version.definition.merge(steps: steps) + definition: definition_parsed } 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 def latest_schema_version Ai::Catalog::ItemVersion::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/spec/factories/ai/catalog/item_versions.rb b/ee/spec/factories/ai/catalog/item_versions.rb index 28658efc9952f0..0ebdcd69c778db 100644 --- a/ee/spec/factories/ai/catalog/item_versions.rb +++ b/ee/spec/factories/ai/catalog/item_versions.rb @@ -33,13 +33,31 @@ trait :for_flow do 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' => [1], - 'steps' => [ - { 'agent_id' => agent.id, 'current_version_id' => agent.latest_version.id, 'pinned_version_prefix' => nil } - ] + 'version' => 'v1', + 'environment' => 'chat', + 'components' => [ + { + 'name' => 'main_agent', + 'type' => 'AgentComponent', + 'prompt_id' => 'test_prompt' + } + ], + 'routers' => [], + 'flow' => { + 'entry_point' => 'main_agent' + }, + 'yaml_definition' => <<~YAML + version: v1 + environment: chat + 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 7da7519f97f887..fcd00bb9e0e272 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 06c517c86c5ad7..fc4e2a59a8b7bd 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 318b769970457d..437685d9d46ad6 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,52 @@ 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) - end + specify { expect(described_class.graphql_name).to eq('AiCatalogFlowVersion') } + specify { expect(described_class.interfaces).to include(::Types::Ai::Catalog::VersionInterface) } - it 'has the expected fields' do - expect(described_class.own_fields.keys).to match_array(%w[ - steps - ]) - end + describe '#definition' do + let(:query) do + %( + query { + aiCatalogItem(id: "#{item.to_global_id}") { + latestVersion { + ... on AiCatalogFlowVersion { + definition + } + } + } + } + ) + end + + let(:returned_definition) { subject.dig('data', 'aiCatalogItem', 'latestVersion', 'definition') } + + subject { GitlabSchema.execute(query, context: { current_user: }).as_json } - it { expect(described_class).to require_graphql_authorizations(:read_ai_catalog_item) } + 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 + + 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 end diff --git a/ee/spec/models/ai/catalog/item_version_spec.rb b/ee/spec/models/ai/catalog/item_version_spec.rb index c7a748414b3da4..197e7aa9db1e0d 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: [\"chat\", \"chat-partial\", \"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/mutations/ai/catalog/flow/create_spec.rb b/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/create_spec.rb index 0b9606366ef7d0..c9b36c1f0e8080 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: chat + 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 @@ -97,12 +111,7 @@ schema_version: 1, 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/update_spec.rb b/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/update_spec.rb index df42829be3d089..5751548f026c11 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: chat + 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) @@ -140,18 +123,9 @@ schema_version: 1, 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 1b9b0e63b78516..8f74fe745bf823 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 91c9ca3ab7916a..7c8f12cdffde76 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: chat + 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,16 +76,8 @@ 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 it 'triggers create_ai_catalog_item', :clean_gitlab_redis_shared_state do @@ -111,23 +112,12 @@ 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' - ) + context 'when the provided YAML is not structured correctly' do + before do + params[:definition] = '"invalid: yaml data' 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 + it_behaves_like 'an error response', ["definition does not have a valid YAML syntax"] end context 'when user is a developer' do @@ -144,32 +134,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 +143,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 9cd9401863fad9..d3bbf66270cdac 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 7f5bec59a6f7e8..7482b28e48e0fa 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: chat + 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,101 +77,21 @@ 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) } + context 'when the provided YAML is not structured correctly' do + let(:params) { super().merge(definition: '"invalid: yaml data') } - it_behaves_like 'an error response', 'Step 1: Agent is private to another project' + it_behaves_like 'an error response', 'Flow definition does not have a valid YAML syntax' 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 + context 'when definition is not provided' do + let(:params) { super().except(:definition) } - let(:params) do - { - item: item, - name: 'New name', - description: 'New description', - public: true, - release: true, - steps: [ - { agent: agent3 }, - { agent: agent } - ] - } - end + it 'does not update the definition' do + existing_definition = latest_version.definition - 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) + expect(latest_version.reload.definition).to eq(existing_definition) end end end -- GitLab From c34db8c7564fbf3457852eaa2fb33a27149c8494 Mon Sep 17 00:00:00 2001 From: Imjaydip Date: Fri, 31 Oct 2025 13:04:47 +0530 Subject: [PATCH 2/6] refactor: remove duplicate code by adding concern for flow YAML definition logic --- .../json_schemas/ai_catalog/flow_v1.json | 4 +- .../concerns/yaml_definition_parser.rb | 49 +++++++++++++++++ .../ai/catalog/flows/create_service.rb | 8 ++- .../ai/catalog/flows/update_service.rb | 24 ++------- .../third_party_flows/create_service.rb | 9 ++-- .../third_party_flows/update_service.rb | 22 ++------ .../ai/catalog/flow_version_type_spec.rb | 42 +-------------- .../ai/catalog/third_party_flow_type_spec.rb | 45 +--------------- .../ai/catalog/flows/create_service_spec.rb | 8 +-- .../ai/catalog/flows/update_service_spec.rb | 18 +------ .../third_party_flows/create_service_spec.rb | 36 +++++-------- .../third_party_flows/update_service_spec.rb | 13 +---- .../definition_field_shared_examples.rb | 45 ++++++++++++++++ .../yaml_definition_shared_examples.rb | 54 +++++++++++++++++++ 14 files changed, 184 insertions(+), 193 deletions(-) create mode 100644 ee/app/services/ai/catalog/concerns/yaml_definition_parser.rb create mode 100644 ee/spec/support/shared_examples/graphql/types/ai/catalog/definition_field_shared_examples.rb create mode 100644 ee/spec/support/shared_examples/services/ai/catalog/yaml_definition_shared_examples.rb diff --git a/app/validators/json_schemas/ai_catalog/flow_v1.json b/app/validators/json_schemas/ai_catalog/flow_v1.json index c343fed2bc9eaa..3d067664af986d 100644 --- a/app/validators/json_schemas/ai_catalog/flow_v1.json +++ b/app/validators/json_schemas/ai_catalog/flow_v1.json @@ -136,7 +136,7 @@ "oneOf": [ { "type": "string", - "pattern": "^[~^]?\\d+\\.\\d+\\.\\d+.*$", + "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')" }, { @@ -283,7 +283,7 @@ "oneOf": [ { "type": "string", - "pattern": "^[~^]?\\d+\\.\\d+\\.\\d+.*$", + "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)" }, { 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 00000000000000..c46a27daba9b98 --- /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 parse_yaml_definition_for_create(item_type = 'Flow') + return yaml_syntax_error(item_type) unless valid_yaml_definition? + + definition_parsed + end + + def build_yaml_version_params(_latest_version) + 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/flows/create_service.rb b/ee/app/services/ai/catalog/flows/create_service.rb index 5336d282ef4a03..dc5cf8416cbf48 100644 --- a/ee/app/services/ai/catalog/flows/create_service.rb +++ b/ee/app/services/ai/catalog/flows/create_service.rb @@ -5,6 +5,7 @@ module Catalog module Flows class CreateService < Ai::Catalog::BaseService include FlowHelper + include Concerns::YamlDefinitionParser def execute return error_no_permissions unless allowed? @@ -16,11 +17,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 = parse_yaml_definition_for_create + return definition if definition.is_a?(ServiceResponse) version_params = { schema_version: ::Ai::Catalog::ItemVersion::FLOW_SCHEMA_VERSION, diff --git a/ee/app/services/ai/catalog/flows/update_service.rb b/ee/app/services/ai/catalog/flows/update_service.rb index 9a84e258c30bc1..c709b620a4908d 100644 --- a/ee/app/services/ai/catalog/flows/update_service.rb +++ b/ee/app/services/ai/catalog/flows/update_service.rb @@ -6,6 +6,7 @@ module Flows class UpdateService < Items::BaseUpdateService extend Gitlab::Utils::Override include FlowHelper + include Concerns::YamlDefinitionParser private @@ -13,16 +14,12 @@ class UpdateService < Items::BaseUpdateService def validate_item return error('Flow not found') unless item && item.flow? - error('Flow definition does not have a valid YAML syntax') unless valid_definition? + yaml_syntax_error unless valid_yaml_definition? end override :build_version_params - def build_version_params(_latest_version) - return {} unless params.key?(:definition) - - { - definition: definition_parsed - } + def build_version_params(latest_version) + build_yaml_version_params(latest_version) end override :save_item @@ -34,19 +31,6 @@ def save_item def latest_schema_version Ai::Catalog::ItemVersion::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/third_party_flows/create_service.rb b/ee/app/services/ai/catalog/third_party_flows/create_service.rb index 7e150378a47d4a..b3e358899a134a 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 = parse_yaml_definition_for_create('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 ad555a5bed51f6..90a647d75dd66f 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 } + def build_version_params(latest_version) + build_yaml_version_params(latest_version) 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/spec/graphql/types/ai/catalog/flow_version_type_spec.rb b/ee/spec/graphql/types/ai/catalog/flow_version_type_spec.rb index 437685d9d46ad6..545ed222a2fb35 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 @@ -10,45 +10,5 @@ specify { expect(described_class.graphql_name).to eq('AiCatalogFlowVersion') } specify { expect(described_class.interfaces).to include(::Types::Ai::Catalog::VersionInterface) } - describe '#definition' do - let(:query) do - %( - query { - aiCatalogItem(id: "#{item.to_global_id}") { - latestVersion { - ... on AiCatalogFlowVersion { - 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 - - it 'returns the yaml_definition' do - expect(returned_definition).to match('test') - end - end - - 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 + 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 ee14e0f2694f9a..6e22ef53bae667 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 @@ -10,48 +10,5 @@ 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 - %( - query { - aiCatalogItem(id: "#{item.to_global_id}") { - latestVersion { - ... on AiCatalogThirdPartyFlowVersion { - 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 - - it 'returns the yaml_definition' do - expect(returned_definition).to match('test') - end - end - - context 'when yaml_definition is not present' do - let(:data) { graphql_data_at(:ai_catalog_item) } - - before do - item.latest_version.definition.delete('yaml_definition') - item.latest_version.definition['image'] = 'some_new_image:22' - item.latest_version.save! - end - - 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' end 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 7c8f12cdffde76..a23ab18b27c902 100644 --- a/ee/spec/services/ai/catalog/flows/create_service_spec.rb +++ b/ee/spec/services/ai/catalog/flows/create_service_spec.rb @@ -112,13 +112,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' 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/flows/update_service_spec.rb b/ee/spec/services/ai/catalog/flows/update_service_spec.rb index 7482b28e48e0fa..a1ba93811207eb 100644 --- a/ee/spec/services/ai/catalog/flows/update_service_spec.rb +++ b/ee/spec/services/ai/catalog/flows/update_service_spec.rb @@ -77,23 +77,7 @@ it_behaves_like 'an error response', 'Flow not found' end - context 'when the provided YAML is not structured correctly' do - let(:params) { super().merge(definition: '"invalid: yaml data') } - - it_behaves_like 'an error response', 'Flow definition does not have a valid YAML syntax' - end - - 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 + 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 5c310c3c663afb..82e58610943502 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 08c6247cb41545..60deb5cd2add5e 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/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 00000000000000..ba1668b4c2f2d6 --- /dev/null +++ b/ee/spec/support/shared_examples/graphql/types/ai/catalog/definition_field_shared_examples.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'AI catalog version definition field' do + describe '#definition' do + let(:query) do + %{ + query { + aiCatalogItem(id: "#{item.to_global_id}") { + latestVersion { + ... 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 + + it 'returns the yaml_definition' do + expect(returned_definition).to match('test') + end + end + + 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 +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 00000000000000..8f416b22a540c5 --- /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 -- GitLab From c383380faafda027dc8d3be0dacd3cd0318bbda7 Mon Sep 17 00:00:00 2001 From: Imjaydip Date: Fri, 31 Oct 2025 18:14:07 +0530 Subject: [PATCH 3/6] Add reviewer suggestion --- .../concerns/yaml_definition_parser.rb | 4 +-- .../ai/catalog/flows/create_service.rb | 2 +- .../ai/catalog/flows/update_service.rb | 4 +-- .../third_party_flows/create_service.rb | 2 +- .../third_party_flows/update_service.rb | 4 +-- .../services/ai/flow_triggers/run_service.rb | 10 ++++-- ee/app/services/ee/issuable_base_service.rb | 2 +- .../ai/flow_triggers/run_service_spec.rb | 32 +++++++++++++++++++ 8 files changed, 48 insertions(+), 12 deletions(-) diff --git a/ee/app/services/ai/catalog/concerns/yaml_definition_parser.rb b/ee/app/services/ai/catalog/concerns/yaml_definition_parser.rb index c46a27daba9b98..e8ca67bbe57eb4 100644 --- a/ee/app/services/ai/catalog/concerns/yaml_definition_parser.rb +++ b/ee/app/services/ai/catalog/concerns/yaml_definition_parser.rb @@ -28,13 +28,13 @@ def yaml_syntax_error(item_type = 'Flow') error("#{item_type} definition does not have a valid YAML syntax") end - def parse_yaml_definition_for_create(item_type = 'Flow') + def parsed_yaml_definition_or_error(item_type = 'Flow') return yaml_syntax_error(item_type) unless valid_yaml_definition? definition_parsed end - def build_yaml_version_params(_latest_version) + def parsed_definition_param return {} unless should_update_definition? { definition: definition_parsed } diff --git a/ee/app/services/ai/catalog/flows/create_service.rb b/ee/app/services/ai/catalog/flows/create_service.rb index dc5cf8416cbf48..c9202f1015f71e 100644 --- a/ee/app/services/ai/catalog/flows/create_service.rb +++ b/ee/app/services/ai/catalog/flows/create_service.rb @@ -17,7 +17,7 @@ def execute project_id: project.id ) - definition = parse_yaml_definition_for_create + definition = parsed_yaml_definition_or_error return definition if definition.is_a?(ServiceResponse) version_params = { diff --git a/ee/app/services/ai/catalog/flows/update_service.rb b/ee/app/services/ai/catalog/flows/update_service.rb index c709b620a4908d..34784a0b835427 100644 --- a/ee/app/services/ai/catalog/flows/update_service.rb +++ b/ee/app/services/ai/catalog/flows/update_service.rb @@ -18,8 +18,8 @@ def validate_item end override :build_version_params - def build_version_params(latest_version) - build_yaml_version_params(latest_version) + def build_version_params(_latest_version) + parsed_definition_param end override :save_item 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 b3e358899a134a..831d20af243c4b 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 @@ -16,7 +16,7 @@ def execute project_id: project.id ) - definition = parse_yaml_definition_for_create('ThirdPartyFlow') + definition = parsed_yaml_definition_or_error('ThirdPartyFlow') return definition if definition.is_a?(ServiceResponse) version_params = { 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 90a647d75dd66f..5d955dfab02703 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 @@ -17,8 +17,8 @@ def validate_item end override :build_version_params - def build_version_params(latest_version) - build_yaml_version_params(latest_version) + def build_version_params(_latest_version) + parsed_definition_param end override :save_item diff --git a/ee/app/services/ai/flow_triggers/run_service.rb b/ee/app/services/ai/flow_triggers/run_service.rb index e7fc051bb42735..4932a88576527d 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 9441af58757891..3539f80fb9d9df 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/services/ai/flow_triggers/run_service_spec.rb b/ee/spec/services/ai/flow_triggers/run_service_spec.rb index 5fe1300124242a..071e63c3968c7e 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 -- GitLab From d5345819fe8923b010ef1ffa9852e2bfd5da1f79 Mon Sep 17 00:00:00 2001 From: Imjaydip Date: Mon, 3 Nov 2025 12:59:43 +0530 Subject: [PATCH 4/6] Add flow_v2 schema to support flow registry based YAML while retaining existing flow_v1 schema --- .../json_schemas/ai_catalog/flow_v1.json | 509 +--------------- .../json_schemas/ai_catalog/flow_v2.json | 570 ++++++++++++++++++ doc/api/graphql/reference/_index.md | 2 +- .../mutations/ai/catalog/flow/create.rb | 2 +- .../types/ai/catalog/flow_version_type.rb | 2 +- ee/app/models/ai/catalog/item_version.rb | 3 +- .../ai/catalog/wrapped_agent_flow_builder.rb | 2 +- ee/spec/factories/ai/catalog/item_versions.rb | 17 + .../ai/catalog/flow_version_type_spec.rb | 18 + .../ai/catalog/third_party_flow_type_spec.rb | 19 + .../experimental_spec.rb | 15 +- .../models/ai/catalog/flow_definition_spec.rb | 2 +- ee/spec/models/ai/catalog/item_spec.rb | 3 +- .../api/graphql/ai/catalog/item_spec.rb | 16 +- .../mutations/ai/catalog/flow/execute_spec.rb | 24 +- .../ai/catalog/flows/create_service_spec.rb | 1 + .../wrapped_agent_flow_builder_spec.rb | 2 +- .../services/ee/issues/create_service_spec.rb | 10 +- .../services/ee/issues/update_service_spec.rb | 6 +- .../ee/merge_requests/update_service_spec.rb | 4 +- .../request_review_service_spec.rb | 2 +- .../definition_field_shared_examples.rb | 50 +- 22 files changed, 699 insertions(+), 580 deletions(-) create mode 100644 app/validators/json_schemas/ai_catalog/flow_v2.json diff --git a/app/validators/json_schemas/ai_catalog/flow_v1.json b/app/validators/json_schemas/ai_catalog/flow_v1.json index 3d067664af986d..2370e2a1c71ddf 100644 --- a/app/validators/json_schemas/ai_catalog/flow_v1.json +++ b/app/validators/json_schemas/ai_catalog/flow_v1.json @@ -1,516 +1,45 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://gitlab.com/gitlab-org/modelops/applied-ml/code-suggestions/ai-assist/schemas/flow_registry_v1_schema.json", - "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" + "triggers", + "steps" ], "additionalProperties": false, "properties": { - "version": { - "type": "string", - "const": "v1", - "description": "Framework version - must be 'v1' for current stable version" - }, - "environment": { - "type": "string", - "enum": [ - "chat", - "chat-partial", - "ambient" - ], - "description": "Flow environment declaring expected level of interaction between human and AI agent" - }, - "components": { + "triggers": { "type": "array", - "minItems": 1, - "description": "List of components that make up the flow", - "items": { - "oneOf": [ - { - "$ref": "#/definitions/AgentComponent" - }, - { - "$ref": "#/definitions/DeterministicStepComponent" - }, - { - "$ref": "#/definitions/OneOffComponent" - } - ] - } + "items": {} }, - "routers": { + "steps": { "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_]+$" - } - }, - "additionalProperties": true - }, - "prompts": { - "type": "array", - "description": "List of inline prompt templates for flow components to use", - "items": { - "$ref": "#/definitions/LocalPrompt" - } - }, - "yaml_definition": { - "type": "string" - } - }, - "allOf": [ - { - "if": { - "properties": { - "environment": { - "const": "chat-partial" - } - } - }, - "then": { + "type": "object", + "required": [ + "agent_id", + "current_version_id", + "pinned_version_prefix" + ], "properties": { - "components": { - "maxItems": 1, - "items": { - "properties": { - "type": { - "const": "AgentComponent" - } - } - } + "agent_id": { + "type": "integer" }, - "routers": { - "maxItems": 0 + "current_version_id": { + "type": "integer" }, - "flow": { - "maxProperties": 0 - } - } - } - } - ], - "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": { + "pinned_version_prefix": { "oneOf": [ { - "type": "string", - "description": "Simple input reference" + "type": "null" }, - { - "$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" + "pattern": "^\\d+(?:\\.\\d+){0,2}$" } ] } }, - "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" - } + "additionalProperties": false } } } 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 00000000000000..876c72041a0903 --- /dev/null +++ b/app/validators/json_schemas/ai_catalog/flow_v2.json @@ -0,0 +1,570 @@ +{ + "$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": [ + "chat", + "chat-partial", + "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" + } + }, + "allOf": [ + { + "if": { + "properties": { + "environment": { + "const": "chat-partial" + } + } + }, + "then": { + "properties": { + "components": { + "maxItems": 1, + "items": { + "properties": { + "type": { + "const": "AgentComponent" + } + } + } + }, + "routers": { + "maxItems": 0 + }, + "flow": { + "maxProperties": 0 + } + } + } + } + ], + "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 f3ad828ecedecd..04c55c7c57e41b 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -2648,7 +2648,7 @@ 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. | +| `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. | diff --git a/ee/app/graphql/mutations/ai/catalog/flow/create.rb b/ee/app/graphql/mutations/ai/catalog/flow/create.rb index 6935d2838d9412..1e86f34e02831c 100644 --- a/ee/app/graphql/mutations/ai/catalog/flow/create.rb +++ b/ee/app/graphql/mutations/ai/catalog/flow/create.rb @@ -40,7 +40,7 @@ class Create < BaseMutation argument :definition, GraphQL::Types::String, required: false, - description: 'YAML definition for the Flow.' + description: 'YAML definition for the flow.' argument :add_to_project_when_created, GraphQL::Types::Boolean, required: false, 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 e4f4010272a97a..d033af00e4f4aa 100644 --- a/ee/app/graphql/types/ai/catalog/flow_version_type.rb +++ b/ee/app/graphql/types/ai/catalog/flow_version_type.rb @@ -20,7 +20,7 @@ class FlowVersionType < ::Types::BaseObject implements ::Types::Ai::Catalog::VersionInterface def definition - object.definition['yaml_definition'] || object.definition.to_yaml + object.definition['yaml_definition'] end end end diff --git a/ee/app/models/ai/catalog/item_version.rb b/ee/app/models/ai/catalog/item_version.rb index d1784287658535..ec905a0e2063c1 100644 --- a/ee/app/models/ai/catalog/item_version.rb +++ b/ee/app/models/ai/catalog/item_version.rb @@ -4,7 +4,8 @@ module Ai module Catalog class ItemVersion < ApplicationRecord 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/wrapped_agent_flow_builder.rb b/ee/app/services/ai/catalog/wrapped_agent_flow_builder.rb index 25b7de1751e49e..2239cddfb8418d 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/spec/factories/ai/catalog/item_versions.rb b/ee/spec/factories/ai/catalog/item_versions.rb index 0ebdcd69c778db..cf0b2ebfd721ad 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,7 +31,23 @@ end end + 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' => [1], + 'steps' => [ + { 'agent_id' => agent.id, 'current_version_id' => agent.latest_version.id, 'pinned_version_prefix' => nil } + ] + } + end + end + trait :for_flow do + schema_version { ::Ai::Catalog::ItemVersion::FLOW_SCHEMA_VERSION } item { association :ai_catalog_flow } definition do { 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 545ed222a2fb35..0ec0d49e04d5f2 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 @@ -7,6 +7,24 @@ let_it_be(:project) { create(:project, maintainers: [current_user]) } let_it_be(:item) { create(:ai_catalog_item, :flow, project: project, public: true) } + let(:query) do + %{ + query { + aiCatalogItem(id: "#{item.to_global_id}") { + latestVersion { + ... 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 } + specify { expect(described_class.graphql_name).to eq('AiCatalogFlowVersion') } specify { expect(described_class.interfaces).to include(::Types::Ai::Catalog::VersionInterface) } 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 6e22ef53bae667..0bd518ae407c48 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,8 +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) } + let(:query) do + %{ + query { + aiCatalogItem(id: "#{item.to_global_id}") { + latestVersion { + ... 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 } + specify { expect(described_class.graphql_name).to eq('AiCatalogThirdPartyFlowVersion') } specify { expect(described_class.interfaces).to include(::Types::Ai::Catalog::VersionInterface) } 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 0c56067490f3cc..d955a755dcc5e7 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 09314d010c610d..21d99cad68b172 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 5b82b9659d1bcf..e9e187f0c4ace3 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/requests/api/graphql/ai/catalog/item_spec.rb b/ee/spec/requests/api/graphql/ai/catalog/item_spec.rb index 0fac77035ec351..4e0540d9fe7e2d 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/execute_spec.rb b/ee/spec/requests/api/graphql/mutations/ai/catalog/flow/execute_spec.rb index 25fa8a1489f629..8461d3af772883 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/services/ai/catalog/flows/create_service_spec.rb b/ee/spec/services/ai/catalog/flows/create_service_spec.rb index a23ab18b27c902..cd05e6972657f9 100644 --- a/ee/spec/services/ai/catalog/flows/create_service_spec.rb +++ b/ee/spec/services/ai/catalog/flows/create_service_spec.rb @@ -78,6 +78,7 @@ version: '1.0.0', definition: YAML.safe_load(definition).merge('yaml_definition' => definition) ) + expect(item.latest_released_version).to eq(item.latest_version) end it 'triggers create_ai_catalog_item', :clean_gitlab_redis_shared_state do 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 20f905901edf11..ecc848dfdc9b60 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/ee/issues/create_service_spec.rb b/ee/spec/services/ee/issues/create_service_spec.rb index 7cff7e9618ebce..e130b4f3462ae3 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 04fbf9194a49e8..9850cfc68d87ce 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/update_service_spec.rb b/ee/spec/services/ee/merge_requests/update_service_spec.rb index 033565a8845da7..3d857151bc0754 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 3f4632e612c8c8..d56701537c042a 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 index ba1668b4c2f2d6..5575abcda21a70 100644 --- 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 @@ -1,45 +1,27 @@ # frozen_string_literal: true RSpec.shared_examples 'AI catalog version definition field' do - describe '#definition' do - let(:query) do - %{ - query { - aiCatalogItem(id: "#{item.to_global_id}") { - latestVersion { - ... on #{described_class.graphql_name} { - definition - } - } - } - } - } + context 'when yaml_definition is present' do + before do + item.latest_version.definition['yaml_definition'] = 'test' + item.latest_version.save! 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 - - it 'returns the yaml_definition' do - expect(returned_definition).to match('test') - end + it 'returns the yaml_definition' do + expect(returned_definition).to match('test') end + end +end - context 'when yaml_definition is not present' do - before do - item.latest_version.definition.delete('yaml_definition') - item.latest_version.save! - 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 + 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 -- GitLab From b401f6fa39142da1731560036cbe2d53c1c16d97 Mon Sep 17 00:00:00 2001 From: Imjaydip Date: Tue, 4 Nov 2025 09:52:38 +0530 Subject: [PATCH 5/6] Fix rspec and reviewer changes --- .../json_schemas/ai_catalog/flow_v2.json | 33 ------------------- ee/spec/factories/ai/catalog/item_versions.rb | 12 +++---- .../models/ai/catalog/item_version_spec.rb | 2 +- .../mutations/ai/catalog/flow/create_spec.rb | 4 +-- .../mutations/ai/catalog/flow/update_spec.rb | 4 +-- .../ai/catalog/flows/create_service_spec.rb | 2 +- .../ai/catalog/flows/update_service_spec.rb | 2 +- .../handle_assignees_change_service_spec.rb | 2 +- 8 files changed, 12 insertions(+), 49 deletions(-) diff --git a/app/validators/json_schemas/ai_catalog/flow_v2.json b/app/validators/json_schemas/ai_catalog/flow_v2.json index 876c72041a0903..169968de27c456 100644 --- a/app/validators/json_schemas/ai_catalog/flow_v2.json +++ b/app/validators/json_schemas/ai_catalog/flow_v2.json @@ -21,8 +21,6 @@ "environment": { "type": "string", "enum": [ - "chat", - "chat-partial", "ambient" ], "description": "Flow environment declaring expected level of interaction between human and AI agent" @@ -82,37 +80,6 @@ "type": "string" } }, - "allOf": [ - { - "if": { - "properties": { - "environment": { - "const": "chat-partial" - } - } - }, - "then": { - "properties": { - "components": { - "maxItems": 1, - "items": { - "properties": { - "type": { - "const": "AgentComponent" - } - } - } - }, - "routers": { - "maxItems": 0 - }, - "flow": { - "maxProperties": 0 - } - } - } - } - ], "definitions": { "ComponentName": { "type": "string", diff --git a/ee/spec/factories/ai/catalog/item_versions.rb b/ee/spec/factories/ai/catalog/item_versions.rb index cf0b2ebfd721ad..ca61adbe217191 100644 --- a/ee/spec/factories/ai/catalog/item_versions.rb +++ b/ee/spec/factories/ai/catalog/item_versions.rb @@ -35,13 +35,9 @@ 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' => [1], - 'steps' => [ - { 'agent_id' => agent.id, 'current_version_id' => agent.latest_version.id, 'pinned_version_prefix' => nil } - ] + 'triggers' => [], + 'steps' => [] } end end @@ -52,7 +48,7 @@ definition do { 'version' => 'v1', - 'environment' => 'chat', + 'environment' => 'ambient', 'components' => [ { 'name' => 'main_agent', @@ -66,7 +62,7 @@ }, 'yaml_definition' => <<~YAML version: v1 - environment: chat + environment: ambient components: - name: main_agent type: AgentComponent diff --git a/ee/spec/models/ai/catalog/item_version_spec.rb b/ee/spec/models/ai/catalog/item_version_spec.rb index 197e7aa9db1e0d..3c71c7bd6755dc 100644 --- a/ee/spec/models/ai/catalog/item_version_spec.rb +++ b/ee/spec/models/ai/catalog/item_version_spec.rb @@ -71,7 +71,7 @@ "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: [\"chat\", \"chat-partial\", \"ambient\"]", + "value at `/environment` is not: ambient", "value at `/components` is not an array", "value at `/routers` is not an array", "value at `/flow` is not an object" 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 c9b36c1f0e8080..ded49d0306bc7b 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 @@ -16,7 +16,7 @@ let(:definition) do <<~YAML version: v1 - environment: chat + environment: ambient components: - name: main_agent type: AgentComponent @@ -108,7 +108,7 @@ 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: YAML.safe_load(definition).merge('yaml_definition' => definition) 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 5751548f026c11..c46c7455ff6e77 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 @@ -36,7 +36,7 @@ let(:definition) do <<~YAML version: v1 - environment: chat + environment: ambient components: - name: updated_agent type: AgentComponent @@ -120,7 +120,7 @@ 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: YAML.safe_load(definition).merge('yaml_definition' => definition) 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 cd05e6972657f9..90bae6c4bc2783 100644 --- a/ee/spec/services/ai/catalog/flows/create_service_spec.rb +++ b/ee/spec/services/ai/catalog/flows/create_service_spec.rb @@ -12,7 +12,7 @@ let(:definition) do <<~YAML version: v1 - environment: chat + environment: ambient components: - name: main_agent type: AgentComponent 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 a1ba93811207eb..9db990b7ea0990 100644 --- a/ee/spec/services/ai/catalog/flows/update_service_spec.rb +++ b/ee/spec/services/ai/catalog/flows/update_service_spec.rb @@ -17,7 +17,7 @@ let(:definition) do <<~YAML version: v1 - environment: chat + environment: ambient components: - name: updated_agent type: AgentComponent 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 a851e2e13167f6..9c541981cb7e28 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) -- GitLab From 0c6537f4b3567e422ae9e7d4209de5dc99580231 Mon Sep 17 00:00:00 2001 From: Imjaydip Date: Tue, 4 Nov 2025 12:14:24 +0530 Subject: [PATCH 6/6] Fix rspec test --- ee/spec/models/ai/catalog/item_version_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/spec/models/ai/catalog/item_version_spec.rb b/ee/spec/models/ai/catalog/item_version_spec.rb index 3c71c7bd6755dc..edae90f05bd326 100644 --- a/ee/spec/models/ai/catalog/item_version_spec.rb +++ b/ee/spec/models/ai/catalog/item_version_spec.rb @@ -71,7 +71,7 @@ "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: ambient", + "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" -- GitLab