diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 4f039d57a..08bff5ef3 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -106,7 +106,7 @@ class Captain::Assistant < ApplicationRecord scenarios: scenarios.enabled.map do |scenario| { title: scenario.title, - key: scenario.title.parameterize.underscore, + key: scenario.handoff_key, description: scenario.description } end, diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb index c43468804..8a6a3c979 100644 --- a/enterprise/app/models/captain/scenario.rb +++ b/enterprise/app/models/captain/scenario.rb @@ -24,6 +24,19 @@ class Captain::Scenario < ApplicationRecord include Concerns::CaptainToolsHelpers include Concerns::Agentable + # OpenAI enforces a 64-char limit on function names. The ai-agents gem + # prepends "handoff_to_" (11 chars), so we keep a safety margin and cap + # the full tool name to MAX_HANDOFF_TOOL_NAME_LENGTH (60 chars). + # Format: "scenario_{id}_{slug}_agent" for persisted records (stable + readable), + # and "scenario_draft_{slug}_agent" for unsaved records, with slug truncated + # based on the available length budget. + HANDOFF_TOOL_PREFIX = 'handoff_to_'.freeze + HANDOFF_KEY_PREFIX = 'scenario'.freeze + HANDOFF_KEY_SUFFIX = 'agent'.freeze + MAX_HANDOFF_TOOL_NAME_LENGTH = 60 + MAX_AGENT_NAME_LENGTH = MAX_HANDOFF_TOOL_NAME_LENGTH - HANDOFF_TOOL_PREFIX.length + MAX_HANDOFF_SLUG_LENGTH = 24 + self.table_name = 'captain_scenarios' belongs_to :assistant, class_name: 'Captain::Assistant' @@ -42,6 +55,10 @@ class Captain::Scenario < ApplicationRecord before_save :resolve_tool_references + def handoff_key + [handoff_id_key, compact_handoff_slug, HANDOFF_KEY_SUFFIX].compact.join('_') + end + def prompt_context { title: title, @@ -56,7 +73,28 @@ class Captain::Scenario < ApplicationRecord private def agent_name - "#{title} Agent".parameterize(separator: '_') + handoff_key + end + + def handoff_id_key + return "#{HANDOFF_KEY_PREFIX}_#{id}" if id.present? + + "#{HANDOFF_KEY_PREFIX}_draft" + end + + def compact_handoff_slug + slug = title.to_s.parameterize(separator: '_').presence + return nil if slug.blank? + + max_slug_length = [MAX_HANDOFF_SLUG_LENGTH, dynamic_slug_max_length].min + return nil if max_slug_length <= 0 + + slug.first(max_slug_length).sub(/_+\z/, '').presence + end + + def dynamic_slug_max_length + # handoff_to_#{scenario___agent} + MAX_AGENT_NAME_LENGTH - handoff_id_key.length - HANDOFF_KEY_SUFFIX.length - 2 end def agent_tools diff --git a/spec/enterprise/models/captain/scenario_spec.rb b/spec/enterprise/models/captain/scenario_spec.rb index 163581f01..94ce5325a 100644 --- a/spec/enterprise/models/captain/scenario_spec.rb +++ b/spec/enterprise/models/captain/scenario_spec.rb @@ -42,6 +42,45 @@ RSpec.describe Captain::Scenario, type: :model do end end + describe '#handoff_key' do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + it 'uses id plus readable slug for persisted scenarios' do + scenario = create(:captain_scenario, assistant: assistant, account: account, + title: 'Handle complex refund requests requiring manager approval steps') + + expect(scenario.handoff_key).to start_with("scenario_#{scenario.id}_") + expect(scenario.handoff_key).to end_with('_agent') + expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60 + end + + it 'uses a truncated slug key for unsaved scenarios' do + scenario = build(:captain_scenario, assistant: assistant, account: account, + title: 'Troubleshoot payment gateway errors for recurring subscription charges') + + expect(scenario.handoff_key).to match(/\Ascenario_draft_[a-z0-9_]+_agent\z/) + expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60 + end + + it 'stays within length budget even for large ids' do + scenario = build(:captain_scenario, assistant: assistant, account: account, + title: 'A very long scenario title used only for budget verification') + allow(scenario).to receive(:id).and_return(1_234_567_890_123_456_789) + + expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60 + end + + it 'exposes handoff keys in assistant prompt context' do + scenario = create(:captain_scenario, assistant: assistant, account: account) + + prompt_context = assistant.send(:prompt_context) + scenario_config = prompt_context[:scenarios].find { |entry| entry[:title] == scenario.title } + + expect(scenario_config[:key]).to eq(scenario.handoff_key) + end + end + describe 'tool validation and population' do let(:account) { create(:account) } let(:assistant) { create(:captain_assistant, account: account) }