feat: move Captain auto-resolve policy to assistants (1/5) (#15299)

Captain auto-resolve policy is now owned by each assistant. Existing
assistants first read an assistant-level setting, fall back to the
current account setting during rollout, and are backfilled
asynchronously so behavior is preserved.

## Closes

[AI-163](https://linear.app/chatwoot/issue/AI-163)

## How to test

1. Configure different auto-resolve modes on two assistants in the same
account through the API.
2. Confirm each assistant follows its own mode when the inactivity job
runs.
3. Confirm an assistant without the new setting follows the existing
account mode.
4. Run the migration job and confirm the account mode is copied without
overwriting an existing assistant mode.
5. Confirm evaluated mode falls back to time-based resolution when the
`captain_tasks` capability is unavailable.

## What changed

- Added assistant-level `disabled`, `legacy`, and `evaluated` policy
storage and validation.
- Updated scheduling and resolution runtime reads to use the assistant
policy.
- Added a compatibility fallback and asynchronous backfill from the
account setting.
- Preserved the account-level `captain_tasks` capability gate for
evaluation.
- Kept this foundation independent of the Captain V2 guard; the timer
and advanced settings in stacks 2–5 are Captain V2-only.

Stack 1 of 5. This is the base for #15303.
This commit is contained in:
Aakash Bakhle
2026-08-04 18:04:53 +05:30
committed by GitHub
parent 0ad2780972
commit e7ded47753
14 changed files with 171 additions and 46 deletions

View File

@@ -14,7 +14,12 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def update
@assistant.update!(assistant_params)
@assistant.with_lock do
permitted_params = assistant_params
permitted_params[:config] = @assistant.config.merge(permitted_params[:config].to_h) if permitted_params[:config]
@assistant.update!(permitted_params)
end
end
def destroy
@@ -119,13 +124,14 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def assistant_params
assistant_config_attributes = [
:product_name, :feature_faq, :feature_memory, :feature_citation,
:feature_contact_attributes, :welcome_message, :handoff_message,
:resolution_message, :instructions, :temperature, :auto_resolve_mode
]
permitted = params.require(:assistant).permit(:name, :description,
config: [
:product_name, :feature_faq, :feature_memory, :feature_citation,
:feature_contact_attributes,
:welcome_message, :handoff_message, :resolution_message,
:instructions, :temperature
])
config: assistant_config_attributes)
# Handle array parameters separately to allow partial updates
permitted[:response_guidelines] = params[:assistant][:response_guidelines] if params[:assistant].key?(:response_guidelines)

View File

@@ -5,9 +5,10 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
queue_as :low
def perform(inbox)
return if inbox.account.captain_auto_resolve_disabled?
captain_assistant = inbox.captain_assistant
return if captain_assistant.blank? || captain_assistant.inactive_conversation_resolution_disabled?
if evaluate_conversation_completion?(inbox.account)
if evaluate_conversation_completion?(captain_assistant, inbox.account)
perform_with_evaluation(inbox)
else
perform_time_based(inbox)
@@ -18,8 +19,8 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
private
def evaluate_conversation_completion?(account)
account.feature_enabled?('captain_tasks') && account.captain_auto_resolve_evaluated?
def evaluate_conversation_completion?(assistant, account)
account.feature_enabled?('captain_tasks') && assistant.evaluate_inactive_conversations_before_resolving?
end
def perform_time_based(inbox)

View File

@@ -10,9 +10,10 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob
def resolve_captain_conversations
CaptainInbox.all.find_each(batch_size: 100) do |captain_inbox|
inbox = captain_inbox.inbox
assistant = captain_inbox.captain_assistant
next if inbox.email?
next if inbox.account.captain_auto_resolve_disabled?
next if assistant.blank? || assistant.inactive_conversation_resolution_disabled?
Captain::InboxPendingConversationsResolutionJob.perform_later(
inbox

View File

@@ -0,0 +1,14 @@
class Migration::CopyCaptainAutoResolveModeToAssistantsJob < ApplicationJob
queue_as :async_database_migration
def perform
Captain::Assistant.includes(:account).find_each do |assistant|
assistant.with_lock do
next if assistant.config.key?('auto_resolve_mode')
config = assistant.config.merge('auto_resolve_mode' => assistant.account.captain_auto_resolve_mode)
assistant.update!(config: config)
end
end
end
end

View File

@@ -18,6 +18,7 @@
#
class Captain::Assistant < ApplicationRecord
DESCRIPTION_LENGTH_LIMIT = 500
AUTO_RESOLVE_MODES = %w[disabled legacy evaluated].freeze
include Avatarable
include Concerns::CaptainToolsHelpers
@@ -41,11 +42,15 @@ class Captain::Assistant < ApplicationRecord
has_many :agent_sessions, class_name: 'Captain::AgentSession', dependent: :destroy_async
has_many :conversation_outcomes, dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name,
:auto_resolve_mode
before_validation :set_default_auto_resolve_mode, on: :create
validates :name, presence: true
validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
validates :account_id, presence: true
validates :auto_resolve_mode, inclusion: { in: AUTO_RESOLVE_MODES }
scope :ordered, -> { order(created_at: :desc) }
@@ -55,6 +60,18 @@ class Captain::Assistant < ApplicationRecord
name
end
def auto_resolve_mode
config.fetch('auto_resolve_mode') { account&.captain_auto_resolve_mode || 'evaluated' }
end
def inactive_conversation_resolution_disabled?
auto_resolve_mode == 'disabled'
end
def evaluate_inactive_conversations_before_resolving?
auto_resolve_mode == 'evaluated'
end
def available_agent_tools
tools = self.class.built_in_agent_tools.dup
@@ -92,6 +109,12 @@ class Captain::Assistant < ApplicationRecord
private
def set_default_auto_resolve_mode
return if config.key?('auto_resolve_mode')
self.auto_resolve_mode = account&.captain_auto_resolve_mode || 'evaluated'
end
def agent_name
name.parameterize(separator: '_')
end

View File

@@ -1,5 +1,5 @@
json.account_id resource.account_id
json.config resource.config
json.config resource.config.merge('auto_resolve_mode' => resource.auto_resolve_mode)
json.created_at resource.created_at.to_i
json.description resource.description
json.guardrails resource.guardrails

View File

@@ -6,7 +6,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
conversation = find_conversation(tool_context.state)
return 'Conversation not found' unless conversation
return "Conversation ##{conversation.display_id} is already resolved" if conversation.resolved?
return 'Auto-resolve is disabled for this account' if conversation.account.captain_auto_resolve_disabled?
return 'Auto-resolve is disabled for this assistant' if @assistant.inactive_conversation_resolution_disabled?
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })