diff --git a/.github/workflows/pullpreview.yml b/.github/workflows/pullpreview.yml index c6f68d01c33b..737ff134bc37 100644 --- a/.github/workflows/pullpreview.yml +++ b/.github/workflows/pullpreview.yml @@ -30,6 +30,7 @@ jobs: echo "OPENPROJECT_FEATURE__SHOW__CHANGES__ACTIVE=true" >> .env.pullpreview echo "OPENPROJECT_FEATURE__AI__TEXT__TRANSFORM__ACTIONS__ACTIVE=true" >> .env.pullpreview echo "OPENPROJECT_FEATURE__LLM__CONNECTION__ACTIVE=true" >> .env.pullpreview + echo "OPENPROJECT_FEATURE__AI__ASSISTANCE__ACTIVE=true" >> .env.pullpreview echo "OPENPROJECT_LOOKBOOK__ENABLED=true" >> .env.pullpreview echo "OPENPROJECT_HSTS=false" >> .env.pullpreview echo "OPENPROJECT_NOTIFICATIONS_POLLING_INTERVAL=10000" >> .env.pullpreview diff --git a/app/models/automations/actions/base.rb b/app/models/automations/actions/base.rb index 8fb660ca9235..959123ed2bbb 100644 --- a/app/models/automations/actions/base.rb +++ b/app/models/automations/actions/base.rb @@ -92,6 +92,10 @@ def priority DEFAULT_PRIORITY end + # Called once the work package is saved with the changes of every action. Actions with + # an effect outside the work package (a notification, a call, a run) do it here. + def after_success(_work_package, _user); end + protected def write_raw_values(new_values) diff --git a/app/models/automations/actions/comment.rb b/app/models/automations/actions/comment.rb new file mode 100644 index 000000000000..9a9ab476133a --- /dev/null +++ b/app/models/automations/actions/comment.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +# Adds a comment to the work package. It runs after Notify, which sets the comment text, +# and appends to it. +class Automations::Actions::Comment < Automations::Actions::Base + include Automations::Actions::Strategies::Text + + def self.key + :comment + end + + def priority + DEFAULT_PRIORITY + 1 + end + + def apply(work_package) + work_package.journal_notes = [work_package.journal_notes.presence, values.first].compact.join("\n\n") + end +end diff --git a/app/models/automations/actions/description.rb b/app/models/automations/actions/description.rb new file mode 100644 index 000000000000..d9683146b198 --- /dev/null +++ b/app/models/automations/actions/description.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Automations::Actions::Description < Automations::Actions::Base + include Automations::Actions::Strategies::Text + + def self.key + :description + end + + def apply(work_package) + work_package.description = values.first + end +end diff --git a/app/models/automations/actions/subject.rb b/app/models/automations/actions/subject.rb new file mode 100644 index 000000000000..a2e3440d615f --- /dev/null +++ b/app/models/automations/actions/subject.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Automations::Actions::Subject < Automations::Actions::Base + include Automations::Actions::Strategies::String + + def self.key + :subject + end + + def apply(work_package) + work_package.subject = values.first + end + + def required? + true + end +end diff --git a/app/models/automations/register.rb b/app/models/automations/register.rb index e1a7b62b07bf..85a0aae82758 100644 --- a/app/models/automations/register.rb +++ b/app/models/automations/register.rb @@ -38,6 +38,9 @@ def actions Automations::Actions::CustomField, Automations::Actions::Type, Automations::Actions::Project, + Automations::Actions::Subject, + Automations::Actions::Description, + Automations::Actions::Comment, Automations::Actions::Notify, Automations::Actions::DoneRatio, Automations::Actions::EstimatedHours, diff --git a/app/models/journal.rb b/app/models/journal.rb index 4d43339bdd80..13bd36f4b1c1 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -91,7 +91,14 @@ class Journal < ApplicationRecord status_changes meeting_id source_meeting_id + source + depth + run_id + skill_key + assistant_name + chat_id message_id + proposal_id ], prefix: true @@ -104,6 +111,8 @@ class Journal < ApplicationRecord ].freeze VALID_CAUSE_TYPES = (%w[ + assistant_chat + assistant_skill default_attribute_written forum_message import diff --git a/app/seeders/development_data/admin_name_seeder.rb b/app/seeders/development_data/admin_name_seeder.rb new file mode 100644 index 000000000000..f9328c03c3d7 --- /dev/null +++ b/app/seeders/development_data/admin_name_seeder.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module DevelopmentData + # Gives the seeded admin user a person's name, so that it reads as a team member in the + # development data. The login stays the same. + class AdminNameSeeder < Seeder + def seed_data! + print_status " ↳ Naming the admin user" do + admin.update!(firstname:, lastname:) + end + end + + def applicable? + firstname.present? && admin.present? && [admin.firstname, admin.lastname] != [firstname, lastname] + end + + private + + def firstname = seed_data.lookup("development_admin.firstname") + + def lastname = seed_data.lookup("development_admin.lastname") + + def admin + seed_data.find_reference(:openproject_admin, default: nil) || admin_user + end + end +end diff --git a/app/seeders/development_data/ai_assistants_seeder.rb b/app/seeders/development_data/ai_assistants_seeder.rb new file mode 100644 index 000000000000..37909bce21bf --- /dev/null +++ b/app/seeders/development_data/ai_assistants_seeder.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module DevelopmentData + # Sample AI assistants of the development data, one for each kind of owner: global, + # a department (its direct members see it) and a user. A sample without a principal + # reference is global. + class AiAssistantsSeeder < BasicData::Llm::AiAssistantSeeder + self.seed_data_model_key = "development_ai_assistants" + self.attribute_names_for_required_references = %w[principal] + + def applicable? + AI::Assistant.where(name: models_data.pluck("name")).none? && + seed_data.all_references_exist?(all_required_references) + end + + def model_attributes(assistant_data) + reference = assistant_data["principal"] + super.merge(principal_id: reference && seed_data.find_reference(reference).id) + end + end +end diff --git a/app/seeders/development_data/custom_actions_seeder.rb b/app/seeders/development_data/custom_actions_seeder.rb new file mode 100644 index 000000000000..23566fbcd737 --- /dev/null +++ b/app/seeders/development_data/custom_actions_seeder.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module DevelopmentData + # Custom actions (automations with a button) that the work packages of the AI Assistance + # demo offer. Runs after the demo data, which creates the statuses and priorities. + class CustomActionsSeeder < Seeder + def seed_data! + print_status " -Creating the custom actions" do + User.execute_as(admin_user) do + custom_actions.each { |custom_action| create_custom_action(custom_action) } + end + end + end + + def applicable? + custom_actions.any? && Automation.where(name: custom_actions.pluck("name")).none? + end + + private + + def create_custom_action(custom_action) + name = custom_action["name"] + attributes = { + name:, + actions: custom_action["actions"].transform_values { |value| [action_value(value)] }, + triggers_attributes: [{ type: Automations::Triggers::Button.name, button_label: name }] + } + + Automations::CreateService.new(user: admin_user) + .call(attributes: attributes.with_indifferent_access) + .on_failure { |result| raise "#{name}: #{result.errors.full_messages.to_sentence}" } + end + + def action_value(value) + seed_data.find_reference(value, default: nil)&.id&.to_s || value + end + + def custom_actions + Array(seed_data.lookup("development_custom_actions")) + end + end +end diff --git a/app/seeders/development_data/refresh_case_seeder.rb b/app/seeders/development_data/refresh_case_seeder.rb new file mode 100644 index 000000000000..2d12ca09aa50 --- /dev/null +++ b/app/seeders/development_data/refresh_case_seeder.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module DevelopmentData + # The Refresh work package demo (ai_refresh_case): a conference work package whose comments + # moved on while its subject, description and fields did not. Runs after the demo data. + class RefreshCaseSeeder < Seeder + def seed_data! + print_status " -Preparing the Refresh work package demo" do + Journal::NotificationConfiguration.with(false) do + update_work_package + add_comments + add_children + end + end + end + + def applicable? + work_package.present? && + seed_data.all_references_exist?(comments.pluck("author")) && + Journal.where(journable: work_package).where(notes: comments.first["notes"]).none? + end + + private + + def update_work_package + work_package.update!( + subject: value("subject"), + description: value("description"), + priority: seed_data.find_reference(value("priority")), + responsible: seed_data.find_reference(value("responsible"), default: nil) + ) + end + + def add_comments + comments.each do |comment| + author = seed_data.find_reference(comment["author"]) + work_package.reload.add_journal(user: author, notes: comment["notes"]) + work_package.save_journals + end + end + + def add_children + Array(value("children")).each do |child| + WorkPackage.create!(subject: child["subject"], type: seed_data.find_reference(child["type"]), **child_defaults) + end + end + + def child_defaults + { project: work_package.project, parent: work_package, author: work_package.author, + status: seed_data.find_reference(:default_status_new), priority: work_package.priority } + end + + def comments + Array(value("comments")) + end + + def value(key) + seed_data.lookup("ai_refresh_case.#{key}") + end + + def work_package + seed_data.find_reference(value("work_package"), default: nil) + end + end +end diff --git a/app/seeders/development_data/team_seeder.rb b/app/seeders/development_data/team_seeder.rb new file mode 100644 index 000000000000..151b2a8c7034 --- /dev/null +++ b/app/seeders/development_data/team_seeder.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module DevelopmentData + # Teams of the development data: groups that are not departments, with members that + # the seed data names by reference. + class TeamSeeder < Seeder + def seed_data! + print_status " ↳ Creating teams" do + seed_data.each("development_teams") do |team_data| + team = Group.create!(lastname: team_data["name"]) + seed_data.store_reference(team_data["reference"], team) + add_members(team, team_data) + end + end + end + + def applicable? + names = Array(seed_data.lookup("development_teams")).pluck("name") + names.any? && Group.where(lastname: names).none? + end + + private + + def add_members(team, team_data) + ids = Array(team_data["members"]).map { |reference| seed_data.find_reference(reference).id } + return if ids.empty? + + Groups::AddUsersService + .new(team, current_user: admin_user) + .call(ids:, send_notifications: false) + .on_failure { |result| raise result.message } + end + end +end diff --git a/app/seeders/development_data/workloads_seeder.rb b/app/seeders/development_data/workloads_seeder.rb new file mode 100644 index 000000000000..f07af1006476 --- /dev/null +++ b/app/seeders/development_data/workloads_seeder.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module DevelopmentData + # Open work packages of the COMMS developers in the demo project, so that their workloads + # differ. Runs after the demo data, which creates the project. + class WorkloadsSeeder < Seeder + def seed_data! + print_status " -Creating the developers' work packages" do + project = self.project + seed_data.each("development_workloads") do |workload| + assignee = seed_data.find_reference(workload["assigned_to"]) + Array(workload["subjects"]).each { |subject| create_work_package(project, subject, assignee) } + end + end + end + + def applicable? + project.present? && subjects.any? && WorkPackage.where(subject: subjects).none? && + seed_data.all_references_exist?(assignee_references) + end + + private + + def create_work_package(project, subject, assignee) + WorkPackage.create!( + project:, + author: admin_user, + subject:, + assigned_to: assignee, + status: seed_data.find_reference(:default_status_new), + type: seed_data.find_reference(:default_type_task), + priority: IssuePriority.default + ) + end + + def workloads + Array(seed_data.lookup("development_workloads")) + end + + def subjects + workloads.flat_map { Array(it["subjects"]) } + end + + def assignee_references + workloads.pluck("assigned_to") + end + + def project + Project.find_by(identifier: "demo-project") + end + end +end diff --git a/app/seeders/development_people_seeder.rb b/app/seeders/development_people_seeder.rb index cfeb136fea19..4299ff722b54 100644 --- a/app/seeders/development_people_seeder.rb +++ b/app/seeders/development_people_seeder.rb @@ -38,6 +38,9 @@ def data_seeder_classes [ DevelopmentData::UserCustomFieldsSeeder, DevelopmentData::DepartmentSeeder, + DevelopmentData::AdminNameSeeder, + DevelopmentData::TeamSeeder, + DevelopmentData::AiAssistantsSeeder, DevelopmentData::WorkingTimeSeeder ] end diff --git a/app/seeders/root_seeder.rb b/app/seeders/root_seeder.rb index c8ced40a75e2..f2acd86c2a37 100644 --- a/app/seeders/root_seeder.rb +++ b/app/seeders/root_seeder.rb @@ -77,8 +77,10 @@ def do_seed! seed_admin_user seed_oauth_data # The demo data references the development users, so they have to exist before it runs. - seed_development_people if seed_development_data? + # On this branch they are seeded everywhere, so that previews show the AI Assistance demo. + seed_development_people seed_demo_data + seed_ai_demo_data seed_development_data if seed_development_data? seed_plugins_data seed_env_data @@ -161,6 +163,14 @@ def seed_demo_data DemoDataSeeder.new(seed_data).seed! end + # The COMMS developers' workloads, the Refresh work package demo and custom actions, on the demo data. + def seed_ai_demo_data + print_status "*** Seeding the AI Assistance demo" + DevelopmentData::WorkloadsSeeder.new(seed_data).seed! + DevelopmentData::RefreshCaseSeeder.new(seed_data).seed! + DevelopmentData::CustomActionsSeeder.new(seed_data).seed! + end + def seed_env_data print_status "*** Seeding data from environment variables" EnvDataSeeder.new(seed_data).seed! diff --git a/app/seeders/standard.yml b/app/seeders/standard.yml index ad7ba672a257..09efbf66c6c7 100644 --- a/app/seeders/standard.yml +++ b/app/seeders/standard.yml @@ -150,6 +150,34 @@ departments: spoken_languages: [English, Italian] key_skills: [Software Development, DevOps] job_start_date: "2018-10-01" + - reference: :user__kabiru_developer + firstname: Kabiru + lastname: Developer + job_title: Software Developer + spoken_languages: [English, French] + key_skills: [Software Development, DevOps] + job_start_date: "2022-02-01" + - reference: :user__bruno_developer + firstname: Bruno + lastname: Developer + job_title: Software Developer + spoken_languages: [English, Portuguese] + key_skills: [Software Development, UX/UI Design] + job_start_date: "2022-06-13" + - reference: :user__judith_developer + firstname: Judith + lastname: Developer + job_title: Software Developer + spoken_languages: [English, German] + key_skills: [Software Development, Quality Assurance] + job_start_date: "2023-01-09" + - reference: :user__ihor_developer + firstname: Ihor + lastname: Developer + job_title: Software Developer + spoken_languages: [English, Polish] + key_skills: [Software Development, DevOps] + job_start_date: "2023-05-22" - reference: :department__finance_administration t_name: Finance & Administration members: @@ -178,6 +206,147 @@ departments: key_skills: [Quality Assurance, Agile & Scrum] job_start_date: "2022-09-05" +# The admin user of the development data has a person's name, as the COMMS team member Tom. +development_admin: + firstname: Tom + lastname: Admin + +# Teams of the development data: groups that are not departments. +development_teams: + - reference: :team__comms + name: COMMS + members: + - :openproject_admin + - :user__kabiru_developer + - :user__bruno_developer + - :user__judith_developer + - :user__ihor_developer + +# The Refresh work package demo, copied from a hand-made test case: the comments and the +# weekly meeting moved on, while the subject, the description and the fields did not. +ai_refresh_case: + work_package: :organize_open_source_conference + subject: OpenProject Community Days 2023 + priority: :default_priority_high + responsible: :user__adam_admin + description: |- + this is gonna happen in November + + This is a hybrid conference with an on-site meetup in Berlin next to the virtual stream. + + refer to a child package that lays out the schedule + + **Update**: The planning call confirmed the addition of a small on-site meetup in Berlin. + + **New Date**: The Berlin venue can only host us on 2026-11-06, so the conference moves to November. + + **Accountability Change**: Wanda Web is now accountable for the conference. + comments: + - author: :openproject_admin + notes: move this to October + - author: :user__olga_ops + notes: "Weekly meeting outcome: the budget is approved. Priority goes up to High." + - author: :user__olga_ops + notes: "Planning call result: we add a small on-site meetup in Berlin next to the virtual stream. This is a hybrid conference now." + - author: :user__olga_ops + notes: Evan takes over as event lead from the Events & Operations team. I stay accountable for the conference. + - author: :user__evan_events + notes: The Berlin venue can only host us on 2026-11-06, so the conference moves to November. + - author: :user__olga_ops + notes: "Small change: Wanda Web is accountable for the conference from now on, not me." + - author: :user__evan_events + notes: Everything is blocked until the venue contract is signed. Let's put this on hold until then. + - author: :user__olga_ops + notes: "Weekly meeting outcome: the conference gets a new name: \"OpenProject Community Days 2026\"." + children: + - subject: unrelated bug blabla + type: :default_type_bug + +# Open work of the COMMS developers in the demo project, so that "Assign to the least busy +# developer" has one answer: Judith has none. +development_workloads: + - assigned_to: :user__kabiru_developer + subjects: + - "[dev] Fix mention autocomplete in comments" + - "[dev] Retry failed reminder emails" + - "[dev] Group digest emails by project" + - assigned_to: :user__bruno_developer + subjects: + - "[dev] Show the unread count on the bell" + - assigned_to: :user__ihor_developer + subjects: + - "[dev] Mark notifications as read in bulk" + - "[dev] Add meeting reminders to calendars" + +# Custom actions (automations with a button) on the work packages of the AI Assistance demo. +# A reference in the action values stands for the id of that record. +development_custom_actions: + - name: Put on hold + actions: + status: :default_status_on_hold + - name: Escalate + actions: + priority: :default_priority_high + comment: "Escalated: needs attention this week." + - name: Assign to me + actions: + assigned_to: current_user + +# Sample AI assistants of the development data, for the owners that the basic data does +# not cover: a team and the admin user. The global one is the basic data's OPAssistant. +development_ai_assistants: + - reference: :ai_assistant_comms + principal: :team__comms + t_name: COMMS assistant + t_description: Helps the COMMS team, which builds the communication features. + t_instructions: You help the COMMS team, which builds the communication features of OpenProject, a project management tool - notifications, email, reminders, mentions, comments, the activity tab and meetings. + position: 1 + skills: + - skill_key: conversation + t_label: Assign to the least busy developer + icon: people + t_description: Proposes the COMMS developer with the fewest open work packages as the assignee. + proposal_scope: changes + prompt: |- + Propose the COMMS developer with the fewest open work packages as the assignee. Make exactly two tool calls, then answer: search_users with the group "COMMS", then search_work_packages with level_of_detail "count", open true, group_by assignee, and assigned_to_id the list of the team members' ids. Name the counts. When the current assignee already has the fewest, say so and propose nothing. Propose nothing else. + + - reference: :ai_assistant_tom + principal: :openproject_admin + t_name: My assistant + t_description: A personal assistant of the admin user. + position: 1 + skills: + - skill_key: conversation + t_label: Refresh work package + icon: sync + t_description: Finds where this work package no longer matches its comments, meetings, descendants and relations, and proposes updates. + proposal_scope: changes + prompt: | + You check one work package of a project management tool for places where it is + inconsistent with itself: its subject, its description, its comments, its status, + its relations, its descendants and its pull requests. Answer with findings. + + Look for: + - comments that changed the scope, a decision or a deadline, which the description + does not show yet; + - meeting notes or outcomes about this work package that the description or the + status does not show yet; + - comments or meeting outcomes that changed the assignee, the accountable person or + the priority; + - a subject that no longer names what the work package is about; + - a description that promises work which the descendants do not cover, or the opposite; + - a status that the comments contradict; + - a closed work package with open descendants, or an open one whose descendants are + all closed; + - a closed work package that follows or is blocked by an open one; + - an open work package that duplicates a closed one; + - a merged pull request while the status is still the default status, or a closed + work package with an open pull request; + - open work, here or in a descendant, with no activity for 60 days. + + Report only what you can show with evidence: quote the comment, or name the field. + Report nothing rather than a guess. + project_custom_field_sections: - reference: :default_project_attributes name: Project attributes @@ -202,6 +371,8 @@ projects: role: :default_role_member - principal: :department__finance_administration role: :default_role_member + - principal: :team__comms + role: :default_role_member project_phases: - definition: :default_project_phase_initiating duration: 20 diff --git a/app/services/automations/update_work_package_service.rb b/app/services/automations/update_work_package_service.rb index 4f307635c23f..8d343646afd8 100644 --- a/app/services/automations/update_work_package_service.rb +++ b/app/services/automations/update_work_package_service.rb @@ -32,11 +32,15 @@ class Automations::UpdateWorkPackageService include Contracted attr_accessor :user, - :action + :action, + :strict - def initialize(action:, user:) + # In strict mode, an action that fails validation fails the whole call, + # instead of being dropped while the other actions apply. + def initialize(action:, user:, strict: false) self.action = action self.user = user + self.strict = strict self.contract_class = ::WorkPackages::UpdateContract end @@ -48,6 +52,8 @@ def call(work_package:, &) model: work_package) .call + action.actions.each { |a| a.after_success(result.result, user) } if result.success? + block_with_result(result, &) end @@ -58,7 +64,7 @@ def apply_actions(work_package, actions) apply_actions_sorted(work_package, actions) success, errors = validate(work_package, user) - retry_apply_actions(work_package, actions, errors, changes_before) unless success + retry_apply_actions(work_package, actions, errors, changes_before) unless success || strict end def retry_apply_actions(work_package, actions, errors, changes_before) diff --git a/app/services/mcp_tools/search_custom_field_items.rb b/app/services/mcp_tools/search_custom_field_items.rb index 477256fb54bd..dad07e7ce4de 100644 --- a/app/services/mcp_tools/search_custom_field_items.rb +++ b/app/services/mcp_tools/search_custom_field_items.rb @@ -30,8 +30,8 @@ module McpTools class SearchCustomFieldItems < Base default_title "Custom Field Items" - default_description "Access items available as values for the given custom field. Usable for hierarchy and " \ - "weighted item list custom fields." + default_description "Access items available as values for the given custom field. Usable for list, hierarchy " \ + "and weighted item list custom fields." name "search_custom_field_items" annotations read_only: true, idempotent: true, destructive: false @@ -53,18 +53,33 @@ class SearchCustomFieldItems < Base def call(custom_field_id:, label: nil) custom_field = ::CustomField.visible(current_user).find_by(id: custom_field_id) + return list_items(custom_field, label) if custom_field&.list? return format_items([]) if custom_field&.hierarchy_root.nil? - items = flatten_hierarchy(custom_field) + format_items(hierarchy_items(custom_field, label)) + end + + private - if label.present? - items = items.select { |item| item.label.to_s.downcase.include?(label.downcase) } - end + def hierarchy_items(custom_field, label) + items = flatten_hierarchy(custom_field) + return items if label.blank? - format_items(items) + items.select { |item| item.label.to_s.downcase.include?(label.downcase) } end - private + # A list field has its values as custom options, not as a hierarchy. + def list_items(custom_field, label) + options = custom_field.custom_options.sort_by(&:position) + options = options.select { |option| option.value.to_s.downcase.include?(label.downcase) } if label.present? + + Success( + { + items: options.map { |option| { id: option.id, label: option.value, default: option.default_value? } }, + total: options.size + } + ) + end def format_items(items) Success( diff --git a/app/services/mcp_tools/search_tool.rb b/app/services/mcp_tools/search_tool.rb index 6e7ed860e4d4..dbbc5ab497d2 100644 --- a/app/services/mcp_tools/search_tool.rb +++ b/app/services/mcp_tools/search_tool.rb @@ -50,7 +50,7 @@ def input_schema(schema = nil) return super(schema.deep_merge({ properties: { page: } })) end - super + with_group_by(super) end ## @@ -90,21 +90,68 @@ def filter(name, filter_class: nil, filter_proc: nil, operator: "=") def filters @filters ||= {} end + + ## + # Defines a field that results can be counted by, through the group_by input parameter. The result then lists + # the count per value of that field next to the total. + # + # @param name [Symbol] The value of group_by that selects this group. + # @param column [String] The SQL column whose values are counted. + # @param association [Symbol] The association to join for the column, if the column is not on the base table. + # @param with [Symbol] Name of an instance method that returns the groups instead, for groups that need more than + # a column. It receives the filtered scope and the applied filters. + # @example + # group :status, column: "statuses.name", association: :status + # + # @example + # group :assignee, with: :assignee_groups + def group(name, column: nil, association: nil, with: nil) + if with.nil? == column.nil? + raise ArgumentError, "Specify either column or with for group #{name}" + end + + groups[name.to_sym] = { column:, association:, with: } + end + + def groups + @groups ||= {} + end + + private + + def with_group_by(schema) + return schema if schema.nil? || groups.empty? + + group_by = { + type: "string", + enum: groups.keys.map(&:to_s), + description: "Count the results per value of this field. The counts are returned as groups next to the total." + } + schema.deep_merge({ properties: { group_by: } }) + end end private - def call(page: nil, level_of_detail: "condensed", **filters_and_scope_params) + def call(page: nil, level_of_detail: "condensed", group_by: nil, **filters_and_scope_params) scope_params = filters_and_scope_params.slice(*scope_param_names) filters = filters_and_scope_params.except(*scope_param_names).reverse_merge(default_filters) base_scope(**scope_params).bind do |scope| filtered = apply_filters(scope, filters) - items, total = apply_pagination(filtered, page) + result = search_result(filtered, page, level_of_detail) + result[:groups] = apply_group(filtered, group_by, filters) if group_by.present? - Success({ items: items.map { |item| apply_lod(format_item(item), level_of_detail:) }, total: }) + Success(result) end end + def search_result(scope, page, level_of_detail) + return { items: [], total: scope.count } if level_of_detail == "count" + + items, total = apply_pagination(scope, page) + { items: items.map { |item| apply_lod(format_item(item), level_of_detail:) }, total: } + end + def apply_filters(scope, params) params.each do |name, value| filter_proc = filter_proc_for(name) @@ -118,6 +165,20 @@ def filter_proc_for(name) self.class.filters[name] || raise(ArgumentError, "Don't know how to handle filter argument called #{name}") end + def apply_group(scope, name, filters) + group = self.class.groups.fetch(name.to_sym) + return send(group[:with], scope, filters) if group[:with] + + column_groups(scope, **group.slice(:column, :association)) + end + + def column_groups(scope, column:, association:) + scope = scope.left_joins(association) if association + scope.group(column).count + .map { |value, count| { name: value || "(none)", count: } } + .sort_by { -it[:count] } + end + def apply_pagination(scope, page) total = scope.count return [scope, total] unless self.class.pagination_enabled? diff --git a/app/services/mcp_tools/search_users.rb b/app/services/mcp_tools/search_users.rb index 50da30f33e40..a38057ade807 100644 --- a/app/services/mcp_tools/search_users.rb +++ b/app/services/mcp_tools/search_users.rb @@ -39,6 +39,12 @@ class SearchUsers < SearchTool enable_pagination filter :search_term, filter_class: "Queries::Users::Filters::AnyNameAttributeFilter", operator: "~" + # The members of a group that the user can see, by its id or its name. + filter :group, filter_proc: ->(users, value) { + groups = Group.visible + groups = value.to_s.match?(/\A\d+\z/) ? groups.where(id: value) : groups.where(lastname: value.to_s) + users.where(id: GroupUser.where(group_id: groups.select(:id)).select(:user_id)) + } input_schema( additionalProperties: false, @@ -47,6 +53,10 @@ class SearchUsers < SearchTool type: "string", description: "A search term to find the user by. Accepts first name and last name. Also accepts email address, " \ "if instance settings allow it." + }, + group: { + type: %w[string number], + description: "The id or the name of a group. Only the members of that group." } } ) diff --git a/app/services/mcp_tools/search_work_packages.rb b/app/services/mcp_tools/search_work_packages.rb index e1d328214527..78c5150e3624 100644 --- a/app/services/mcp_tools/search_work_packages.rb +++ b/app/services/mcp_tools/search_work_packages.rb @@ -33,8 +33,11 @@ class SearchWorkPackages < SearchTool default_description "Search work packages matching all of the passed input parameters. " \ "Parameters not passed are ignored. Results are limited to a maximum " \ "of #{page_size} work packages. To get the rest of the results, call the tool again with a " \ - "page number of 2 or higher. Names of custom fields should be resolved through the corresponding tool " \ - "before showing them to the user. They should not be rendered as 'customFieldN'." + "page number of 2 or higher. The total in the result is the number of all matching work " \ + "packages. To only count them, pass level_of_detail 'count', which returns no items. " \ + "Pass group_by to also get the count per value of one field. Names of custom fields should " \ + "be resolved through the corresponding tool before showing them to the user. They should " \ + "not be rendered as 'customFieldN'." name "search_work_packages" annotations read_only: true, idempotent: true, destructive: false @@ -54,30 +57,47 @@ class SearchWorkPackages < SearchTool filter :author_id filter :id, filter_proc: ->(wps, v) { wps.where_display_id_in(v) } filter :project_id - filter :status_id + filter :status_id, filter_proc: ->(wps, v) { wps.where(status_id: Array(v)) } + filter :open, filter_proc: ->(wps, v) { v ? wps.with_status_open : wps.with_status_closed } filter :type_id filter :version_id, filter_proc: FILTER_ON_TARGET_VERSIONS filter :target_version_id, filter_proc: FILTER_ON_TARGET_VERSIONS filter :subject, filter_proc: ->(wps, v) { wps.where("subject ILIKE '%#{OpenProject::SqlSanitization.quoted_sanitized_sql_like(v)}%'") } + group :status, column: "statuses.name", association: :status + group :type, column: "types.name", association: :type + group :priority, column: "enumerations.name", association: :priority + group :project, column: "projects.name", association: :project + group :assignee, with: :assignee_groups + input_schema( additionalProperties: false, properties: { level_of_detail: { type: :string, - enum: %w[reduced full], + enum: %w[reduced full count], description: "Defines how much information is returned per work package. By default a reduced representation " \ - "is returned, but passing 'full' can return all information." + "is returned, but passing 'full' can return all information. Passing 'count' returns no " \ + "work packages, only the total and the groups." }, assigned_to_id: { - type: %w[number null], - description: "The ID of the user or group that is assigned to this work package. " \ + type: %w[number array null], + items: { type: "number" }, + description: "The ID of the user or group that is assigned to this work package, or a list of IDs. " \ "Pass null to search for work packages without an assignee." }, author_id: { type: "number", description: "The ID of the user that created this work package." }, id: { type: %w[string number], description: "The identifier of the work package." }, project_id: { type: "number", description: "The ID of the project that this work package belongs to." }, - status_id: { type: "number", description: "The ID of the work package's status." }, + status_id: { + type: %w[number array], + items: { type: "number" }, + description: "The ID of the work package's status, or a list of status IDs." + }, + open: { + type: "boolean", + description: "true: only work packages with an open status. false: only work packages with a closed status." + }, subject: { type: "string", description: "The subject of the work package. Accepts partial subjects, not case-sensitive." @@ -107,6 +127,37 @@ def format_item(item) API::V3::WorkPackages::WorkPackageRepresenter.create(item, current_user:) end + # Names only principals that the user can see. Each assignee asked for by id is listed, + # also with no work packages, so that a missing person is not a question. + def assignee_groups(scope, filters) + requested = Array(filters[:assigned_to_id]).compact.map(&:to_i) + counts = scope.group(:assigned_to_id).count + names = visible_names(counts.keys.compact | requested) + + assignee_totals(counts, requested, names) + .map { |key, count| assignee_group(key, count, names) } + .sort_by { -it[:count] } + end + + def assignee_totals(counts, requested, names) + totals = requested.select { names.key?(it) }.index_with(0) + totals.default = 0 + counts.each { |id, count| totals[id.nil? || names.key?(id) ? id : :not_visible] += count } + totals + end + + def assignee_group(key, count, names) + case key + when nil then { name: "(none)", count: } + when :not_visible then { name: "(not visible)", count: } + else { id: key, name: names[key], count: } + end + end + + def visible_names(ids) + Principal.visible(current_user).where(id: ids).to_h { |principal| [principal.id, principal.name] } + end + def condensed_attributes @condensed_attributes ||= %w[ _links diff --git a/app/views/automations/_form.html.erb b/app/views/automations/_form.html.erb index 5cfd0954907e..6d30195aa5ea 100644 --- a/app/views/automations/_form.html.erb +++ b/app/views/automations/_form.html.erb @@ -165,10 +165,14 @@ See COPYRIGHT and LICENSE files for more details. <%= angular_component_tag "opce-custom-date-action-admin", data: { "field-value": date.try(:iso8601) || date, "field-name": input_name } %> - <% elsif %i(string_property text_property).include?(action.type) %> + <% elsif action.type == :string_property %>
<%= styled_text_field_tag input_name, action.values, container_class: "-slim" %>
+ <% elsif action.type == :text_property %> +
+ <%= styled_text_area_tag input_name, action.values.first, rows: 5 %> +
<% elsif action.type == :link_property %>
<%= styled_url_field_tag input_name, action.values, container_class: "-slim" %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 08a11a28319f..6e70f8afbf51 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3884,6 +3884,9 @@ en: journals: cause_descriptions: + assistant_chat: proposed in the chat and applied by %{user_name} + assistant_chat_link: "Open the chat" + assistant_skill: proposed by %{assistant_name} and applied by %{user_name} budget_deleted: Budget has been deleted forum_message_deleted: "(deleted)" import: @@ -3945,6 +3948,8 @@ en: non_working: "%{day} is now non-working" working: "%{day} is now working" caused_changes: + assistant_chat: "AI Assistance:" + assistant_skill: "AI Assistance:" budget_deleted: "Budget has been deleted" dates_changed: "Dates changed" default_attribute_written: "Read-only attributes written" diff --git a/docs/development/concepts/ai-assistance/2026-10-14-agents.md b/docs/development/concepts/ai-assistance/2026-10-14-agents.md new file mode 100644 index 000000000000..c7436f4bc77f --- /dev/null +++ b/docs/development/concepts/ai-assistance/2026-10-14-agents.md @@ -0,0 +1,254 @@ +--- +sidebar_navigation: + title: Agent integration +description: How OpenProject starts external AI agents from the AI Assistance menu, how they read and write as the user, and how they run contained +keywords: AI, assistants, agents, opilot, MCP, OAuth, delegation, breakdown, coding agent +--- + +# Agent integration: OpenProject work + +This document describes how OpenProject starts an external AI agent from the AI Assistance menu. The agent reads the work package, can ask questions, proposes changes, and, after the user confirms, writes them through API v3 as the user. The first agent is opilot, but the contract is the same for every agent. + +The concept README explains which work goes to an agent and which stays in core (see "Core skills and agent skills" in the [concept](./README.md)). In short: a skill that creates or links several objects, uses tools in a loop, runs for minutes, or reaches systems outside OpenProject is an **agent skill**. A skill that changes fields of one work package with one LLM call is a **core skill**. The user sees no difference. + +The first agent skill is **Break down into tasks**: the agent proposes child work packages, and the user creates them with one click. The coding agent (plan, options, questions, pull request) is the second one. + +## Decisions + +- **The agent acts as the user, and the history names the agent.** OpenProject mints tokens for the user who started the session, issued to the agent connection's OAuth application. The agent can do only what the user can do. Each change shows the user and the agent: "Jane Doe, via opilot". This is the meaning of the `act` claim of RFC 8693 (OAuth Token Exchange), without the exchange grant. +- **Reading and writing use separate tokens.** At the start, the agent gets a read token. It gets a write token only after the user confirms a proposal. The write token has a narrow scope, `agent_write`, and lives about 15 minutes. +- **The user confirms each batch.** The agent sends its plan as a proposal of typed operations, for example "create 5 child work packages". OpenProject renders the card and checks the user's permissions. One click on "Create" confirms the whole batch. +- **The write token is not bound to the confirmed operations.** Within the allowed endpoints and the user's permissions, the agent could do more than the user confirmed until the token expires. This is an accepted risk with mitigations (see "The agent_write scope"). +- **Core sends the context.** The start request carries the same work package context as the core skills. The agent can read more through the MCP server when it is available, but it does not need it. +- **The agent is an optional add-on.** Core skills need only an LLM connection. Agent skills appear when an admin adds an agent connection. +- **The agent runs contained.** In the same Docker Compose or Kubernetes stack, it has no route to the database, the cache or the secrets of OpenProject (see "Deployment and containment"). + +### Prior art + +- **Linear** installs an agent with OAuth and `actor=app`. The agent is an app user with its own name and icon. Assigning an issue to it sets it as the **delegate**; the human keeps ownership. An Agent Session has the states pending, active, awaitingInput, complete, error and stale, and the agent sends activities: thought, action, elicitation, response and error. The developer hosts the agent and receives webhooks. +- **GitHub Copilot coding agent** commits as its own bot, with the requesting person as co-author and a link to the session logs. Its token can push only to `copilot/*` branches, and a person must approve its workflows and its pull request. +- **Atlassian Rovo** records a change that a user confirms in the chat as the user's change. Users ask for an agent identity that shows "on behalf of" the user (AUTO-2432). +- **RFC 8693** keeps the user in `sub` and the agent in `act`, so a service can log both. + +We follow Linear's session and activity model, GitHub's guardrails (narrow write access, a person confirms), and RFC 8693's attribution. + +## Flow: Break down into tasks + +```mermaid +sequenceDiagram + participant U as User + participant OP as OpenProject + participant A as Agent + U->>OP: AI Assistance → Break down into tasks + OP->>OP: create the session, mint the read token (mcp) + OP->>A: POST {base_url}/runs (signed): context, read token, callback + A-->>OP: callback: thought, then proposal (create 5 children) + OP-->>U: proposal card: 5 tasks, "Create 5 work packages" + U->>OP: optional: "split task 2" (message) + OP->>A: POST {base_url}/runs/{id}/messages (signed) + A-->>OP: callback: new proposal + U->>OP: Create + OP->>OP: check permissions, store the confirmed operations, mint the write token (agent_write) + OP->>A: POST {base_url}/runs/{id}/confirm (signed): proposal id, write token + A->>OP: POST /api/v3/work_packages (× 5), Authorization: Bearer write token + OP->>OP: journals get the cause "via agent" + A-->>OP: callback: response (links), finished + OP->>OP: revoke both tokens, compare journals with the confirmed operations + OP-->>U: in-app notification: result ready +``` + +## Work items + +The items are in the order in which they can be built. + +### 1. Agent connections (admin) + +An admin configures where an agent runs. + +- **Model** `AI::AgentConnection`, table `ai_agent_connections`: + - `name` (shown in the history: "via "), `base_url`, `active`; + - `signing_secret`, stored encrypted; + - an owned Doorkeeper application, through the polymorphic `integration` association (`lib/open_project/patches/doorkeeper_application.rb`), as `ScimClient` does (`app/models/scim_client.rb`, `app/services/scim_clients/create_service.rb`); + - `skill_types`: the agent skill types it offers, for example `breakdown` and `coding_agent`; + - `timeout_minutes`, default 60. +- **Contracts and services:** create, update and delete. The create service also creates the Doorkeeper application. The delete service revokes its tokens. +- **Validation of `base_url`:** HTTPS is required, except for `localhost` and internal service names in development. +- **Admin pages:** Administration → Artificial Intelligence (AI) → Agent connections. + +### 2. Identity and tokens + +OpenProject mints the tokens itself. It needs no token exchange grant: Doorkeeper (`config/initializers/doorkeeper.rb`) has `authorization_code`, `client_credentials` and refresh tokens only. + +- **`AI::AgentConnections::MintTokenService`** creates a `Doorkeeper::AccessToken` with the connection's application (the actor), the user who started the session as `resource_owner_id` (the subject), and the scope and lifetime of the token kind. +- **Read token:** scope `mcp`, at most one hour. +- **Write token:** scope `agent_write` only, never together with `api_v3`. Minted after the user confirms a proposal, valid about 15 minutes. +- **An agent application's token is never an ordinary token.** The OAuth strategy refuses it for the `api_v3` scope unless it has `agent_write`, and then only on the allowed endpoints. +- **The read token is read-only on MCP.** The MCP server has write tools (`create_work_package`, `update_work_package`, `create_work_package_comment` and the relation tools; `config/initializers/mcp.rb`), and an admin switches them for the whole instance, not for each client. For a token of an agent application, `lib/api/mcp.rb` offers read-only tools only. +- **`AI::AgentConnections::RevokeTokensService`** revokes tokens when the session finishes, fails, is cancelled or times out. +- **Plaintext tokens** are sent once and are never stored. OpenProject keeps only the token ids. +- **The token in the request.** The OAuth strategy (`lib_static/open_project/authentication/strategies/warden/doorkeeper_oauth.rb`) stores the access token in the Rack env, so later code can find the agent application and the session. + +### 3. Agent skill types + +- **`breakdown`** (Break down into tasks) and **`coding_agent`** (Build), both conversations. +- **Global assistants only.** The skill form in My account does not offer agent skill types, because the connection and its costs are the admin's. +- **Skill setting:** the agent connection (`agent_connection_id` on `ai_assistant_skills`). +- **Ready check** (`AI::AssistantSkill#ready?`): the connection is active and offers the skill type. An agent skill needs no LLM connection in OpenProject. +- **Menu:** while a session runs, the item shows "Running". + +### 4. Agent sessions + +- **Model** `AI::AgentSession`, table `ai_agent_sessions`, one for each conversation: + - `chat_id`, `agent_connection_id`; + - `state`: `pending`, `active`, `awaiting_input`, `complete`, `error`, `stale` (as in Linear); + - `external_id`, `read_token_id`, `write_token_id`, `callback_secret_digest`; + - `last_event_id`, `request_count`, `started_at`, `finished_at`. +- **One running session** for each user, work package and skill. + +### 5. Outbound requests + +- **`AI::AgentSessions::DispatchJob`** sends the start request, the user's messages, the confirmation and the cancellation, in the background. +- **Signature:** an HMAC-SHA256 of the raw body with the connection's signing secret, in `X-OpenProject-Signature: sha256=`, with `X-OpenProject-Timestamp`. The agent refuses old requests. +- **Start request** `POST {base_url}/runs`: + + ```json + { + "session_id": "…", + "skill": { "type": "breakdown", "label": "Break down into tasks", "prompt": "…" }, + "instance_url": "https://openproject.example.com", + "work_package": { "id": 1234, "display_id": "PROJ-12", "url": "…/work_packages/1234" }, + "user": { "id": 5, "name": "Jane Doe", "locale": "en" }, + "message": "the user's text, or empty", + "context": { "…": "AI::Assistance::WorkPackageContext#to_h, with change_options" }, + "mcp": { "url": "…/mcp", "access_token": "…", "expires_at": "…" }, + "callback": { "url": "…/ai_assistance/agent_sessions/…/events", "secret": "…" } + } + ``` + + `context` is the context that the core skills get (`app/services/ai/assistance/work_package_context.rb`). `mcp` is present only when the MCP server is enabled. +- **Message** `POST {base_url}/runs/{external_id}/messages` with `{ "message": "…" }`. +- **Confirmation** `POST {base_url}/runs/{external_id}/confirm` with `{ "proposal_id": "…", "api": { "url": "…/api/v3", "access_token": "…", "expires_at": "…" } }`. +- **Cancel** `POST {base_url}/runs/{external_id}/cancel`. +- **Failures:** retry a network error or a 5xx three times with backoff, then mark the session `error`. A 4xx fails at once. +- **Log:** each request and its status become events in the "Details" timeline, without tokens or secrets. + +### 6. The callback endpoint + +- **Route:** `POST /ai_assistance/agent_sessions/:id/events`, with `Authorization: Bearer `. No user session and no CSRF token. A finished session refuses events. +- **Event body:** `{ "event_id": "…", "kind": "…", "data": { … } }`. A repeated `event_id` is ignored. +- **Event kinds** (aligned with Linear's activities): + + | Kind | Data | Shown as | + |---|---|---| + | `thought` | `text` | The newest progress line. The agent sends one within 10 seconds of the start. | + | `action` | `text`, optional `result` | A line in Details | + | `elicitation` | `markdown` | An assistant message; the state becomes `awaiting_input` | + | `proposal` | `id`, `summary`, `operations` | A proposal card | + | `response` | `markdown`, optional `links` | An assistant message | + | `error` | `reason` | An error banner; the tokens are revoked | + | `finished` | `summary` | The state becomes `complete`; the tokens are revoked | + +### 7. Proposals with typed operations + +```json +{ + "id": "p-2", + "summary": "Five tasks for the conference", + "operations": [ + { "id": "op-1", "kind": "create_work_package", + "attributes": { "subject": "Book the Berlin venue", "description": "…", "type": "Task", "assignee": "Olga Ops" }, + "parent": "self" }, + { "id": "op-2", "kind": "create_relation", "from": "$op-1", "to": 10, "type": "precedes" }, + { "id": "op-3", "kind": "add_comment", "work_package": "self", "text": "…" } + ] +} +``` + +- **Vocabulary:** `create_work_package`, `create_relation`, `add_comment`. An operation can refer to an earlier one (`$op-1`). +- **Checks before "Create":** the names resolve (types of the project, assignable users); the user has the permissions of each operation (`add_work_packages`, `manage_subtasks`, `manage_work_package_relations`, `add_work_package_comments`); at most 10 operations. +- **Confirm:** "Create" stores the confirmed operations on the session, mints the write token, and sends the confirmation. +- **Field changes stay in core.** An agent can also propose field changes in the Automations vocabulary (`AI::Proposal`); core applies them through `ApplyProposalService`. The executor depends on the operation, not on the skill. + +### 8. The `agent_write` scope + +- **Doorkeeper:** `:agent_write` in `optional_scopes`. +- **Strategy:** accepts an `agent_write` token under the `api_v3` warden scope, for agent applications only. +- **Endpoint allowlist** (`lib/api/root_api.rb`): an agent token reaches only + - `POST /api/v3/work_packages` + - `POST /api/v3/work_packages/form` + - `POST /api/v3/work_packages/:id/relations` + - `POST /api/v3/work_packages/:id/activities` + + Everything else answers 403. +- **Accepted risk: no plan binding.** Within the allowlist and the user's permissions, the agent could do more than the user confirmed until the token expires. Mitigations: + - the token lives about 15 minutes and is revoked on `finished`; + - a request cap for each session; + - an after-the-fact check: core compares the journals with this session's cause against the confirmed operations, and flags differences; + - every change carries the agent cause. +- **Considered and not chosen:** + - **Plan binding:** each request matches one confirmed operation exactly. A check of a few fields is not enough, because the create endpoint takes many more attributes. About six cross-cutting changes. + - **Core executes the confirmed operations:** no write token, about one service for each operation kind. It moves the create logic into core, which this design avoids. It stays the fallback if the after-the-fact check finds misuse. + +### 9. Attribution + +- **Cause:** `Journal::CausedByAgent`, type `agent`, with `source`, `depth`, `agent_connection_id` and `agent_name`. It is in `VALID_CAUSE_TYPES` and in the cause formatter. +- **Request-scoped default cause:** `Journal::RequestCause`, a thread-local value like `Journal::NotificationConfiguration`, set for each request with an agent token in a block with `ensure`. +- **In the save hook:** `with_ensured_journal_attributes` uses the request cause when `journal_cause` is blank. Services that set their own cause keep it. +- **No merging:** `Journals::CreateService` never merges a journal with an agent cause into another journal, and never merges another journal into it, unless both have the same cause. +- **Activity text:** "Jane Doe, via opilot". +- **Known gaps:** background jobs that a request starts lose the thread-local cause; the parent's derived-attribute journals keep their own cause; notification mails credit the change to the user. + +### 10. The dialog + +- **Reuse** `ChatDialogComponent`, `ChatComponent` and `ChatFormComponent`, with the same poll path. +- **While the agent works:** a spinner with the newest `thought`. The user can close the dialog; the session keeps running. +- **Proposal card:** the operations as a list, with "Create N work packages", then the links to the results. +- **Stop:** sends the cancel request, marks the session `error`, revokes the tokens. + +### 11. Notifications + +- A new reason `ai_result_ready`, created with `Notifications::CreateService` on the work package, as reminders do (`app/workers/reminders/schedule_reminder_job.rb`). Sent on `proposal`, `elicitation` and `finished`. + +### 12. Timeouts and cleanup + +- **`AI::AgentSessions::TimeoutJob`** (cron) marks sessions `stale` after the connection's timeout, or after 15 minutes without an event, and revokes their tokens. + +### 13. Permissions and gating + +- **Permission:** `use_ai_assistance`, as for core skills. +- **Operation permissions:** checked before the write token is minted, and again by the API on each request. + +### 14. Deployment and containment + +The agent is an optional part of the Docker Compose files and the Helm chart, off by default. When it is on, it brings its network rules. + +**The threat:** the agent's harness runs commands that a model chooses and reads text that users wrote. A prompt injection must not reach the database, the cache, the attachment storage or the secrets of OpenProject. + +- **Networks:** + - the **harness** is on an internal network with the two gateways only; + - the **runner** reaches the OpenProject web service and the gateways, never the database, the cache or the workers; + - the **gateways** hold the only egress; + - **OpenProject** reaches the agent's inbound API, which checks the signature. +- **Docker Compose:** separate networks, `internal: true` for the harness network. +- **Helm:** a separate namespace; default-deny NetworkPolicies (they work only with a CNI that enforces them); `automountServiceAccountToken: false`; the "restricted" Pod Security Standard. +- **No shared secrets or volumes** with OpenProject. +- **Hardened containers:** non-root, read-only root file system, no capabilities, the default seccomp profile, no Docker socket, resource limits. gVisor or Kata are optional for the harness. +- **Tokens:** the session tokens go to the runner only; the harness never sees them. + +### 15. Changes in opilot + +- A Rack app in the runner ("serve" mode) for `/runs`, `/runs/{id}/messages`, `/runs/{id}/confirm`, `/runs/{id}/cancel` and `/health`, with the signature check. +- A client for each session with a Bearer token. +- mcp-gw with the session's token, set by the runner. +- `OpenProject::CreateWp` reused: the draft becomes a `proposal` event, and the creates run after `confirm` with the write token. +- A callback sink for `OpenProject::Agent#handle_elsewhere`. + +## Open questions + +- **Hosting:** one agent for several OpenProject instances, or one for each? +- **Enterprise:** do agent connections need an Enterprise feature? +- **Costs:** should OpenProject limit sessions for each user or project per day? +- **Updates:** should `agent_write` later allow updates, and for which fields? +- **No user present:** do scheduled agent runs need a `ServiceAccount`? +- **Plan binding:** should it come later, if the after-the-fact check finds misuse? +- **MCP coverage:** MCP has no tools for meetings, outcomes or descendants. diff --git a/docs/development/concepts/ai-assistance/README.md b/docs/development/concepts/ai-assistance/README.md new file mode 100644 index 000000000000..f7610fba0a08 --- /dev/null +++ b/docs/development/concepts/ai-assistance/README.md @@ -0,0 +1,245 @@ +--- +sidebar_navigation: + title: AI Assistance +description: How the AI Assistance menu, its assistants and skills, and Refresh work package work, and how this change is split for review +keywords: AI, assistants, skills, refresh work package, health check, automations, LLM +--- + +# Development concept: AI Assistance + +The "AI Assistance" menu on the work package page offers skills, for example "Refresh work package", "Write a test plan" or a chat. Every skill opens a conversation. An answer is text with proposals, and the user applies or dismisses each proposal. Every skill starts with a click, uses the permissions of the user who clicked, and writes nothing until that user applies a proposal. + +This document describes the design. It also shows how the first pull request is split into smaller pull requests for review. + +## Terms + +| Term | Meaning | +|---|---| +| **Assistant** | A record (`AI::Assistant`): a name, an icon, instructions and an optional model. An admin configures global and group assistants, and a user can create personal ones (see "Owners"). It is not a user. In the menu, its name is the label next to each of its skills, for example "OPAssistant". | +| **Skill** | A record (`AI::AssistantSkill`) on an assistant: a label, a system prompt, a proposal scope, conditions, and the setting "Answer at once when opened". What it does is in its prompt. | +| **Skill type** | Where a skill runs (`AI::AssistantSkills::Registry`). Core has one, `conversation`. An external agent can be a second one later (see "Core skills and agent skills"). | +| **Chat** | One conversation of a user with a skill about one work package (`AI::AssistantChat`), with its messages (`AI::AssistantChatMessage`). Only that user sees it. | +| **Proposal** | One proposed change in an answer (`AI::AssistantProposal`). A **finding** has a severity, an area and evidence, and an optional change set. A **plain proposal** has a short text and a change set. | +| **Change set** | A proposed change in the Automations action vocabulary: a target (only `self` for now) and a list of actions with values, for example `{"target": "self", "actions": [{"key": "description", "values": ["…"]}]}`. Which action keys it may use depends on the skill's proposal scope (see "Proposal scope and changeable attributes"). The user applies it through the Automations services. | + +## Flow + +```mermaid +flowchart LR + U([User]) -->|click| M["AI Assistance menu
(Primer ActionMenu)"] + M -->|answers at once: POST start| S["SendMessageService"] + M -->|empty chat: GET show| D + S --> J["ReplyJob
(as the user)"] + J --> LLM["LlmConnection
(structured output)"] + LLM --> P[("Answer + proposals")] + P --> D["Chat dialog
(polls while the job runs)"] + D -->|Apply| A["ApplyProposalService"] + A -->|strict, lockVersion| AU["Automations::UpdateWorkPackageService"] + AU --> WP["Work package
+ journal cause"] +``` + +1. The work package toolbars have an "AI Assistance" dropdown: a labelled button in the full view, and an icon button in the split view. The Angular component `op-wp-ai-assistance-slot` loads `AIAssistance::MenusController#show` for the work package into a turbo frame. A user without the permission gets an empty frame, and so does a work package that the user cannot see. After a proposal changed the work package, the component reloads it, so the next inline edit does not conflict. +2. The menu frame renders one flat item for each skill that is ready (`AI::AssistantSkill#ready?`) of each active assistant, and a "History" item at the end. +3. A skill that answers at once posts to `ChatsController#start`: a new chat that starts with a start message (role `start`). `#rerun` adds another start to the same chat. Any other skill opens `ChatsController#show`, an empty chat for the user's own question. +4. `AI::AssistantChats::SendMessageService` stores the question or the start and a pending answer, and `ReplyJob` answers as the user. The dialog polls `#poll` until the answer is done or failed. +5. The user applies a proposal or dismisses it, with an optional reason (`ChatsController#apply`, `#dismiss`). + +## Decisions + +- **Assistants are records, not users.** An answer always acts with the permissions of the user who asked. The job runs in `User.execute_as(chat.user)`. +- **One runtime, one answer format.** Every skill answers in a conversation, and every answer is text with 0..n proposals (`AI::Assistance::AnswerFormat`). Whether a skill lists findings is up to its prompt. There are no built-in rules: the seeded "Refresh work package" asks the model for what rules used to check. +- **What the AI reads.** It reads only what the user can see (`AI::Assistance::WorkPackageContext`, with `AI::Assistance::Visibility`): the project name; the work package, with its default-status flag and last activity; visible descendants, with their parent, status and update time; visible relations, with their status; linked pull requests that the user may read; comments filtered by `journals.internal_visible`; and the agendas of the newest 10 visible meetings that have the work package on the agenda or in an outcome. The prompt tells the model that this text is user data, not instructions. +- **Read-only MCP tools.** For data outside the context, for example other work packages or counts, the model can call OpenProject's read-only MCP tools (`AI::Assistance::McpTool`). They run in-process, through RubyLLM's tool loop, as the chat user, so each tool's own visibility rules apply. No token and no HTTP. Write tools are never offered: changes stay proposals. `search_time_entries` is left out. A model whose `function_calling` verdict is `unsupported` gets no tools. Each skill can opt out with "Use OpenProject MCP tools" (`use_tools`, on by default); the full system prompt on the skill page follows that checkbox. The admin's MCP tool switches apply to external MCP clients only. +- **Tool calls are limited.** RubyLLM has no limit, so `AI::Assistance::ToolCalls` counts them: an identical call gets the earlier result, every call after 20 calls or after three repeated calls gets "answer now", and the answer fails after 25. Each call is a `tool_call` event in "Details", with its name, arguments, time and result size, but not the result. While the answer loads, the dialog lists the calls so far. +- **Evidence is required for findings.** A finding without evidence is dropped, and so is a plain proposal without a valid change. The "stored" event in "Details" lists each dropped proposal with its reason. The changed fields give the area of a finding (`AnswerFormat::CHANGE_AREAS`), because models label it inconsistently. Without such a field, the model's area applies, and a finding with the area "none" has no area. When an answer has proposals and all of them are dropped, `Reply` asks the model once more, with the dropped answer and the reason for each proposal. Proposals are listed by severity, then by the first changed field (`AnswerFormat::FIELD_ORDER`), then by area, so that the order does not depend on the model. Text and evidence are shown as escaped text. Only `http(s)` URLs and work package ids become links. +- **Stable answers.** Every call uses temperature 0.2, so one work package gives similar answers from run to run. +- **Change sets are Automations actions.** `AI::Assistance::ApplyProposalService` looks up each action key in `Automations::Register`. It refuses actions that `ChangeOptions#allowed?` does not allow now: keys outside the skill's proposal scope, and values that are not valid now. It calls `Automations::UpdateWorkPackageService` with `strict: true`, so an invalid action fails the call instead of being dropped. +- **Conflicts use a strict `lockVersion`.** Each proposal stores the work package version from which it was made (`base_lock_version`). If the work package changed after that, the apply fails and the user asks again. After a successful apply, the other open proposals of the same chat move to the new version, so a second proposal can apply. +- **Content guard.** A description change that removes an attachment reference, a mention or a macro cannot be applied (`AI::Assistance::ContentGuard`). +- **One answer at a time** in each chat. An answer that is still pending after 10 minutes fails. +- **Journal cause.** An applied proposal writes `Journal::CausedByAssistantChat` with the assistant's name, so the activity tab says which assistant proposed the change. It carries `source` and `depth` for the loop protection of a later trigger model. Older journals with the cause `assistant_skill` still render. +- **Automations stay the write layer.** Existing Automations do not change. The only change that admins can see is three new action types: Subject, Description and Comment. + +## Prompts + +The system prompt of a skill has three parts (`AI::AssistantSkill#system_prompt`): + +1. **The assistant prompt** (`ai_assistants.instructions`, optional): what every skill of the assistant shares, for example who it helps and the domain. Edited on the assistant page. +2. **The skill prompt** (`prompt`): what the model does in this skill. Edited on the skill page. Without it, the skill is not offered. +3. **The output rules**, which the code generates (`AI::Assistance::OutputRules`): the answer format and language, findings and plain proposals, the changes that the proposal scope allows, that the newest comment or meeting wins when two disagree, how to use the tools (`TOOLS`, only when tools are sent), and the guard against instructions in the context and the tool results. Admins and users do not edit them, so a skill cannot break the contract with the code. + +The work package context (JSON) follows the system prompt. The message is the user's question, with the last 20 turns before it. An earlier answer is sent with a short list of its proposals and their state, so the model knows what it proposed. + +The skill page shows the full system prompt under the skill prompt field. It changes while the admin types (`ai-assistance--prompt-preview`), and its tool rule follows "Use OpenProject MCP tools". The "Details" timeline of each answer shows the prompt that was sent. The basic data seeder creates the global "OPAssistant" with its instructions and two skills (`modules/llm/app/seeders/common.yml`): Refresh work package and Chat. + +## Skills + +A skill has one behavior setting, **"Answer at once when opened"** (on by default): + +- **On:** opening the skill starts a new chat, and the skill answers at once. The chat shows a start line ("Tom started Refresh work package"), not a question. The LLM gets a fixed, translated request (`ai_assistance.chat.start_request`) as the user turn. "Run again" starts the skill again in the same chat, on the current work package. The user can ask follow-up questions. Examples: Refresh work package, Write a test plan. +- **Off:** opening the skill shows the user's latest chat of this skill, or an empty one, and the user writes the first question. Example: Chat. + +"New chat" starts an empty conversation. An assistant can have several skills, each with its own label, prompt, scope and conditions. + +## Owners + +An assistant has an optional owner, `ai_assistants.principal_id`: + +- **None:** a global assistant. Everyone with `use_ai_assistance` sees it. +- **A group:** only the group's direct members see it. An admin chooses the group when creating the assistant (Administration → AI → Assistants). +- **A user:** a personal assistant (see below). + +The development data seeds a sample for each owned kind (`DevelopmentData::AiAssistantsSeeder`, data in `app/seeders/standard.yml`). The global one is the basic data's OPAssistant. + +- **COMMS assistant** (the COMMS team, which builds the communication features; `DevelopmentData::TeamSeeder` seeds the team with the admin user as its member): "Write a test plan". +- **Tom's assistant** (personal, of the admin user): "My next steps". + +The owner never changes (`attr_readonly`). Other principal types can be added later (`AI::Assistant::OWNER_TYPES`). The menu shows global assistants first, then owned ones (`AI::Assistant.visible_to`, `.ordered`). Owned assistants are deleted with their principal, together with their skills and chats. + +### Personal assistants + +A user can create their own assistants with their own skills at **My account → AI assistants** (`My::AIAssistantsController`, `My::AIAssistantSkillsController`). + +- **Permission:** the global permission `manage_own_ai_assistants`. An admin gives it through a global role. To give it to all users, add it to the Standard global role. +- **Menu:** a personal assistant shows only in its owner's menu, after the global assistants. When the owner loses the permission, the assistant stays, but it is not offered (`AI::Assistant#usable?`). +- **Model:** `Llm::Runtime` resolves the model of the `:ai_assistance` feature, with the assistant's model as the override. An assistant model that the connection does not offer is ignored. Without an assistant model, the feature binding applies, then the connection default. A personal assistant has no model of its own. Only an admin can set a model, also on a personal assistant. +- **Admin:** the admin list shows all assistants with their owner, and filters by owner (Global, Groups, Personal). The admin edits, deactivates and deletes personal assistants on the same pages. +- **Shared pages:** the admin and the My account controllers include `AIAssistance::AssistantsActions` and `AIAssistance::AssistantSkillsActions`, and render the views in `modules/llm/app/views/ai_assistants/` and `modules/llm/app/views/ai_assistant_skills/`. `AIAssistance::Pages` gives the paths and the breadcrumbs of each place. + +## Skill conditions + +A skill can be limited to work packages of some types, statuses and projects, and to users with some roles in the work package's project (`ai_assistant_skills.conditions`, a jsonb hash of id lists). An empty list matches all. The menu, and so the chats controller, offer only skills whose conditions match (`AI::AssistantSkill#conditions_fulfilled?`). The skill page edits them under "Where it is offered". + +The rules are the same as the conditions of Automations, but the code is separate for now: Automations store them in join tables and filter in SQL. A deleted type, status, project or role stays in the list until the skill is saved again; it then matches nothing. + +## Proposal scope and changeable attributes + +A skill's proposal scope (`ai_assistant_skills.proposal_scope`) is **Changes** (attribute changes and comments), **Comment only** or **Read-only** (nothing). A comment is the Automations action `Comment`, and it needs only the permission to comment. There is no list of attributes to maintain in the UI: with custom fields, such a list would grow without limit. + +| Layer | Set by | Example: Refresh work package | +|---|---|---| +| What a skill can change (`changeable_attributes`, `ChangeOptions::ATTRIBUTE_KEYS`) | Code | `subject`, `description`, `status`, `priority`, `assigned_to`, `responsible`, `start_date`, `due_date`, and a `comment` | +| The proposal scope | Admin, on the skill page | Changes | +| Which values are valid now (`AI::Assistance::ChangeOptions`) | The workflow and the user's permissions | `assignable_statuses`, `assignable_priorities`, `assignable_assignees`, `assignable_responsibles` of `WorkPackages::UpdateContract` | + +The model gets the valid values as `change_options`, with names, not ids. `ChangeOptions#resolve` maps each answer to an id or an ISO date, and drops anything else. `ApplyProposalService` checks every action again with `ChangeOptions#allowed?` before it applies a proposal, so an admin who narrows a skill's scope after an answer also blocks its open proposals. + +The dialog shows text changes as a diff, and other changes as "old → new". + +## Core skills and agent skills + +Some skills run in OpenProject (core skills), and some run in an external agent (agent skills). The user does not see the difference. + +- **Core skill:** it reads one work package, makes one LLM call through the LLM connection, and proposes changes to that work package in the Automations vocabulary. Refresh work package, Chat and Custom prompt are core skills. +- **Agent skill:** it creates or links several objects, uses tools in a loop, runs for minutes, or reaches systems outside OpenProject. Break down into tasks and the coding agent are agent skills. See [Agent integration](./2026-10-14-agents.md). +- **Neither:** a feature with one correct result, for example PDF generation, stays deterministic code. + +**One user experience.** Both kinds are entries in the same menu, use the same dialog, show their proposals on the same cards, and are listed in the same History. Every change in the activity tab names the user and the assistant or agent that proposed it. + +**Who decides where something runs:** + +1. **The skill type** (admin time): a core skill type runs in core; an agent skill type runs in the agent connection that the admin selects. +2. **The operation** (answer time): a field change or a comment is applied by core through `ApplyProposalService`. Creating work packages and relations is done by the agent after the user confirms. +3. **The apply step** (apply time): core checks the user's permissions before anything runs, also for the agent. + +The model and the user never decide it. + +**Why not everything in the agent:** every instance would need a running agent, even for a summary; the agent would depend on the MCP server, which is an Enterprise add-on; one LLM call is faster and cheaper than an agent run; and the contract between core and the agent must stay compatible across releases. + +## Prepared for an Automations trigger model + +The code does not have triggers, but it uses the shapes that a trigger model needs: + +- **Change set in the action vocabulary.** The same shape can serve an AI proposal, an automation step and the answer of an external service. +- **`Automations::Actions::Base#after_success`.** It is called after a successful save. It is the extension point for actions with an effect outside the work package: start a skill, call a webhook, create a work package. +- **Sending and answering are separate.** `SendMessageService` stores the question and the pending answer; `ReplyJob` answers it. A job can start a conversation without a request. +- **The chat's user is the only actor.** The reply and apply never read `User.current`. A conversation that a schedule starts can act as a service account. +- **The journal cause carries `source` and `depth`.** +- **Not done yet:** a chat does not record its trigger. A trigger model adds `trigger` (`menu`, `automation`, `schedule`) and `automation_id` to `ai_assistant_chats`. + +## Gating + +- The feature flag `ai_assistance` (off by default in production). Skills also need the `llm_connection` flag, the `llm_features_enabled` setting and a configured connection with a model. +- The project permission `use_ai_assistance` (view level). The seeded Member role gets it. Personal assistants also need the global permission `manage_own_ai_assistants`. Applying a proposal also needs `edit_work_packages`, or only `add_work_package_comments` for a comment. +- There is no enterprise gate yet. See "Follow-ups". + +## Data model + +| Table | Holds | +|---|---| +| `ai_assistants` | name (unique for each owner), description, `instructions`, icon, `model_id` (a string: syncing models replaces `llm_models` rows), position, active, `principal_id` (the owner: a user or a group) | +| `ai_assistant_skills` | `assistant_id`, `skill_key` (`conversation`), label, description, `prompt`, `proposal_scope`, `conditions`, `settings` (`answers_at_once`), position, active | +| `ai_assistant_chats` | skill, user, work package | +| `ai_assistant_chat_messages` | chat, role, content, status, locale, `base_lock_version`, `events` | +| `ai_assistant_proposals` | message, position, source, severity, area, text, `evidence`, `change_set`, `base_lock_version`, state, `dismiss_reason` | + +Chats, messages and proposals are deleted with their user, their work package or their skill (foreign keys with `on_delete: :cascade`). The basic data seeder creates the "OPAssistant" with its two skills when there is no global assistant, also on an upgrade. + +## How to try it + +1. Enable `ai_assistance` at `/admin/settings/experimental`. +2. Give "Use AI assistance" to a role, or use the seeded Member role. +3. Configure an LLM connection with a default chat model. +4. Open a work package, and use **AI Assistance → Refresh work package**. To see proposals, add a comment that changes the scope, or close the work package while a child is still open. +5. Configure assistants at **Administration → Artificial Intelligence (AI) → Assistants**. +6. For personal assistants, add "Manage own AI assistants" to a global role, then open **My account → AI assistants**. + +## Split for review + +This change comes as one pull request so that it can be tried end to end. Each commit is one review step, except the commit that adds this document. The steps can be cut into separate pull requests in this order, each one stacked on the one before it: + +| # | Commit | Contents | Depends on | +|---|---|---|---| +| 1 | Add Subject and Description automation actions and a strict mode | Three Automations action types (Subject, Description, Comment), a text area for text actions, `strict:` and the `after_success` hook in `Automations::UpdateWorkPackageService` | – | +| 2 | Add the ai_assistance flag, permission and journal cause | Feature flag, `use_ai_assistance`, seeded role, `Journal::CausedByAssistantSkill` | – | +| 3 | Add AI assistant, skill, run and finding models | Migration, four models, seeder, factories | 2 | +| 4 | Add the skill registry and the health check tree rules | Registry, `HealthCheck::Context`, four tree rules | 3 | +| 5 | Add the health check relation and staleness rules | Three rules | 4 | +| 6 | Add the health check rules for linked pull requests | GitHub and GitLab rules, "Not checked" entries | 4 | +| 7 | Add the run service, the run controllers and the run dialog | `RunService`, runs and findings controllers, dismiss, Primer dialog and history | 4 | +| 8 | Add the AI Assistance menu to the work package toolbars | Angular slot, menu button and items | 7 | +| 9 | Add the AI review to the health check | Job, review, model choice (`AI::Assistant#effective_model_id`), single-job guard, polling | 7 | +| 10 | Apply proposed changes from AI findings | `ApplyProposalService`, content guard, diff view, toolbar refresh | 1, 9 | +| 11 | Add admin pages for AI assistants and their skills | Admin controllers, Primer forms, menu entry | 3 | +| 12 | Add the AI Assistance chat on work packages | Chat tables and models, reply job, chat skill type, chat dialog, `Journal::CausedByAssistantChat` | 10, 11 | +| 13 | Add custom prompt presets, a comment-only scope and a copy button | Custom prompt skill type, `ChatsController#start`, `proposal_scope`, copy button | 12 | +| 14 | Use "AI review" in the interface | Wording | 9 | +| 15 | Rename the AI judgement to AI review, and move shared proposal classes to AI::Assistance | Renames only | 14 | +| 16 | Clean up AI Assistance after review | Shared LLM call, event log and locale helpers, dead code, seeded prompts, this document | 15 | +| 17 | Add personal AI assistants in My account | Owner column, `manage_own_ai_assistants`, My account pages, shared admin and My controller logic | 11 | +| 18 | Let a group own an AI assistant | `principal_id` owner, group choice in admin, menu for group members | 17 | +| 19 | Offer skills only where their conditions match | `conditions` on skills, the menu filter, the skill form in sections | 11 | +| 20 | Generate the output rules, and share instructions across an assistant's skills | `AI::Assistance::OutputRules`, `instructions` on assistants, the prompt preview, the assistant form in sections | 11 | +| 21 | Store the proposals of a conversation answer as their own records | `ai_assistant_proposals`, several proposals for each answer, the journal cause names the assistant | 12 | +| 22 | Replace the AI skill types with one conversation type and settings | One `conversation` type, `settings` on skills, the skill form | 21 | +| 23 | Answer with findings in the conversation, and with several proposals | Findings in the chat reply, the answer timeout | 22 | +| 24 | Give AI skills a first message instead of start and answer settings | One answer format (`AnswerFormat`), simpler settings | 23 | +| 25 | Open every AI skill in the conversation, with its proposals as cards | "Answer at once when opened", `ProposalItemComponent`, dismiss in the chat | 24 | +| 26 | Remove AI runs and the built-in rules, and check consistency with the LLM | Runs, findings and rules removed; the context gets pull requests, the default status and activity times; the seeded prompt | 25 | +| – | Add an open filter and counts to the MCP work package tools | `open` and a status list on `search_work_packages`, `level_of_detail` "count" and a generic `group_by` on search tools | – | +| – | Let AI Assistance skills call the read-only MCP tools | `AI::Assistance::McpTool`, `ToolCalls`, the tool rule, the project id in the context, `tool_call` events | the MCP tool step, 26 | +| – | Show AI tool calls while the answer loads, and stop repeated calls | The poll shows new events while pending, id normalization, the repeat limit | the step before | + +Steps 21–26 replace runs with conversations. For review, they are best folded into the steps they change (7, 9, 10, 12 and 13), so that runs, rules and the three skill types never appear. This table is updated when that is done. + +Steps 1 and 2 are useful on their own and can merge first. + +## Module + +The code is in the `openproject-llm` module (`modules/llm`), with the LLM connection it uses. The toolbar dropdown is a core Angular component, because the work package toolbars have no plugin extension point. The engine holds the feature flag, the LLM feature, the routes, the menus, the permissions, the inflections and the `Group` and `User` associations. The basic data seeder is `BasicData::Llm::AiAssistantSeeder`, with its data and the role permission in `modules/llm/app/seeders/common.yml`. + +These parts stay in core: + +- `app/models/journal.rb`, `lib/open_project/journal_formatter/cause.rb` (the `assistant_chat` cause), as the meeting cause types do. +- The Automations actions (Subject, Description, Comment, strict mode) and the MCP tool changes, as their own pull requests. +- The development data of the demo (`DevelopmentData::AiAssistantsSeeder`, the team, the workloads and the Refresh case, data in `standard.yml`), which the core seeders call. + +## Follow-ups + +- **Agent skills.** Start an external agent (opilot first) from the menu: Break down into tasks, then the coding agent. The OpenProject work is in [Agent integration](./2026-10-14-agents.md). +- **Enterprise gate.** Gate the menu, the chats and the admin pages behind the existing `custom_actions` enterprise feature, as the last step. +- **Specs.** Model, service, request and component specs cover the chat, the proposals and the admin pages. The toolbar and the chat dialog need browser specs (full view and split view). +- **Automations can start a skill.** A "Run assistant skill" action type that uses `after_success` and starts a conversation with `trigger: "automation"`, and notifies the user. It gives skills the triggers of Automations. The result is still a proposal. The action offers only the skills of global assistants: a personal skill must not run for other users. +- **MCP tools.** Decide the gating: the Enterprise `mcp_server` feature and the admin's per-tool switches apply to `/mcp`, but not yet to the chat. Decide about `search_time_entries`. Tool results are not kept for later turns, so a follow-up question calls the tools again. Weaker models (gpt-4o-mini) use the tools badly: they skip `group_by` and read data that the context holds. Recommend a Sonnet-class model. +- **Edit before apply.** Open a proposed description in the description editor. +- **Retention.** Chats are kept without a limit. A cleanup job can come when usage shows the need. diff --git a/frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.html b/frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.html new file mode 100644 index 000000000000..a4d30e6da888 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.html @@ -0,0 +1,3 @@ +@if (enabled) { + +} diff --git a/frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.ts b/frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.ts new file mode 100644 index 000000000000..6cd84769724f --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-ai-assistance/wp-ai-assistance-slot.component.ts @@ -0,0 +1,100 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { + ChangeDetectionStrategy, + Component, + inject, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { ConfigurationService } from 'core-app/core/config/configuration.service'; +import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; +import { WORK_PACKAGE_UPDATED_EVENT } from 'core-stimulus/helpers/my-time-management-entry-menus'; + +/** + * Places the Rails-rendered AI Assistance menu into the work package toolbar, and reloads + * the work package after a proposal from that menu changed it, so the next inline edit does + * not conflict. + */ +@Component({ + selector: 'op-wp-ai-assistance-slot', + templateUrl: './wp-ai-assistance-slot.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class WorkPackageAiAssistanceSlotComponent implements OnInit, OnDestroy { + private readonly pathHelper = inject(PathHelperService); + private readonly configuration = inject(ConfigurationService); + private readonly apiV3Service = inject(ApiV3Service); + private readonly halEvents = inject(HalEventsService); + + @Input() public workPackage:WorkPackageResource; + + @Input() public compact = false; + + public enabled = false; + + public turboFrameSrc:string; + + private readonly onUpdated = (event:Event):void => { + const detail = (event as CustomEvent<{ work_package_id?:number|string }>).detail; + if (String(detail?.work_package_id) !== String(this.workPackage.id)) { + return; + } + + void this.apiV3Service + .work_packages + .id(this.workPackage.id!) + .refresh() + .then((workPackage) => this.halEvents.push(workPackage, { eventType: 'updated' })); + }; + + ngOnInit():void { + this.enabled = this.configuration.activeFeatureFlags.includes('aiAssistance'); + if (!this.enabled) { + return; + } + + const url = new URL(`${this.pathHelper.staticBase}/ai_assistance/menu`, window.location.origin); + url.searchParams.set('work_package_id', this.workPackage.id!); + if (this.compact) { + url.searchParams.set('compact', 'true'); + } + this.turboFrameSrc = url.toString(); + + document.addEventListener(WORK_PACKAGE_UPDATED_EVENT, this.onUpdated); + } + + ngOnDestroy():void { + document.removeEventListener(WORK_PACKAGE_UPDATED_EVENT, this.onUpdated); + } +} diff --git a/frontend/src/app/features/work-packages/components/wp-details/wp-details-toolbar.html b/frontend/src/app/features/work-packages/components/wp-details/wp-details-toolbar.html index 3706b4ff49dd..4e3006188aa3 100644 --- a/frontend/src/app/features/work-packages/components/wp-details/wp-details-toolbar.html +++ b/frontend/src/app/features/work-packages/components/wp-details/wp-details-toolbar.html @@ -17,6 +17,8 @@ /> } + +