collavre 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/app/channels/collavre/comments_presence_channel.rb +6 -3
  3. data/app/controllers/collavre/admin/orchestration_controller.rb +177 -0
  4. data/app/controllers/collavre/comments_controller.rb +10 -1
  5. data/app/javascript/controllers/comments/presence_controller.js +5 -1
  6. data/app/jobs/collavre/ai_agent_job.rb +55 -4
  7. data/app/jobs/collavre/cron_action_job.rb +53 -0
  8. data/app/jobs/collavre/stuck_detector_job.rb +34 -0
  9. data/app/models/collavre/comment.rb +15 -15
  10. data/app/models/collavre/creative.rb +1 -1
  11. data/app/models/collavre/orchestrator_policy.rb +124 -0
  12. data/app/models/collavre/task.rb +3 -0
  13. data/app/models/collavre/user.rb +1 -1
  14. data/app/services/collavre/ai_agent_service.rb +32 -1
  15. data/app/services/collavre/ai_client.rb +4 -3
  16. data/app/services/collavre/comments/command_processor.rb +4 -1
  17. data/app/services/collavre/comments/topic_command.rb +101 -0
  18. data/app/services/collavre/orchestration/agent_context_builder.rb +201 -0
  19. data/app/services/collavre/orchestration/agent_orchestrator.rb +142 -0
  20. data/app/services/collavre/orchestration/arbiter.rb +257 -0
  21. data/app/services/collavre/orchestration/loop_breaker.rb +294 -0
  22. data/app/services/collavre/orchestration/matcher.rb +98 -0
  23. data/app/services/collavre/orchestration/policy_resolver.rb +170 -0
  24. data/app/services/collavre/orchestration/resource_tracker.rb +135 -0
  25. data/app/services/collavre/orchestration/scheduler.rb +145 -0
  26. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +231 -0
  27. data/app/services/collavre/orchestration/stuck_detector.rb +303 -0
  28. data/app/services/collavre/system_events/context_builder.rb +34 -7
  29. data/app/services/collavre/system_events/dispatcher.rb +2 -7
  30. data/app/services/collavre/tools/cron_cancel_service.rb +49 -0
  31. data/app/services/collavre/tools/cron_create_service.rb +73 -0
  32. data/app/services/collavre/tools/cron_list_service.rb +65 -0
  33. data/app/services/collavre/tools/cron_update_service.rb +82 -0
  34. data/app/views/admin/shared/_tabs.html.erb +1 -0
  35. data/app/views/collavre/admin/orchestration/show.html.erb +66 -0
  36. data/app/views/collavre/creatives/index.html.erb +1 -1
  37. data/config/locales/ai_agent.en.yml +21 -0
  38. data/config/locales/ai_agent.ko.yml +21 -0
  39. data/config/locales/comments.en.yml +8 -0
  40. data/config/locales/comments.ko.yml +8 -0
  41. data/config/routes.rb +5 -1
  42. data/db/migrate/20260206005035_create_orchestrator_policies.rb +32 -0
  43. data/db/migrate/20260206094509_add_retry_count_to_tasks.rb +5 -0
  44. data/db/migrate/20260206100000_add_topic_id_to_tasks.rb +6 -0
  45. data/lib/collavre/version.rb +1 -1
  46. metadata +24 -13
  47. data/app/controllers/collavre/github_auth_controller.rb +0 -25
  48. data/app/models/collavre/github_account.rb +0 -10
  49. data/app/models/collavre/github_repository_link.rb +0 -19
  50. data/app/services/collavre/github/client.rb +0 -112
  51. data/app/services/collavre/github/pull_request_analyzer.rb +0 -280
  52. data/app/services/collavre/github/pull_request_processor.rb +0 -181
  53. data/app/services/collavre/github/webhook_provisioner.rb +0 -130
  54. data/app/services/collavre/system_events/router.rb +0 -96
  55. data/app/views/collavre/creatives/_github_integration_modal.html.erb +0 -77
  56. data/db/migrate/20250925000000_create_github_integrations.rb +0 -26
  57. data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +0 -29
  58. data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +0 -5
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Orchestration
5
+ # Arbiter decides which agents will actually respond from the qualified candidates.
6
+ # This implements "floor control" - preventing multiple agents from responding simultaneously.
7
+ #
8
+ # Strategies:
9
+ # - all: All candidates respond (original behavior)
10
+ # - primary_first: Primary agent responds, others only if primary unavailable
11
+ # - round_robin: Rotate between agents for each message
12
+ # - bid: Select agent based on relevance score (expertise matching)
13
+ #
14
+ class Arbiter
15
+ # Default confidence threshold for bid strategy
16
+ DEFAULT_CONFIDENCE_THRESHOLD = 0.3
17
+ def initialize(context, policy_resolver: nil)
18
+ @context = context
19
+ @policy_resolver = policy_resolver || PolicyResolver.new(context)
20
+ end
21
+
22
+ # Select which agents will respond from the candidates
23
+ # @param candidates [Array<User>] Qualified agents from Matcher
24
+ # @return [Array<User>] Agents that will actually respond
25
+ def select(candidates)
26
+ return [] if candidates.empty?
27
+
28
+ strategy = @policy_resolver.arbitration_strategy
29
+ selected = apply_strategy(strategy, candidates)
30
+
31
+ # Apply max_responders limit if set
32
+ max = @policy_resolver.max_responders
33
+ selected = selected.take(max) if max.present? && max.positive?
34
+
35
+ selected
36
+ end
37
+
38
+ private
39
+
40
+ def apply_strategy(strategy, candidates)
41
+ case strategy
42
+ when "all"
43
+ strategy_all(candidates)
44
+ when "primary_first"
45
+ strategy_primary_first(candidates)
46
+ when "round_robin"
47
+ strategy_round_robin(candidates)
48
+ when "bid"
49
+ strategy_bid(candidates)
50
+ else
51
+ # Unknown strategy, default to all
52
+ Rails.logger.warn("[Arbiter] Unknown strategy '#{strategy}', falling back to 'all'")
53
+ strategy_all(candidates)
54
+ end
55
+ end
56
+
57
+ # All candidates respond
58
+ def strategy_all(candidates)
59
+ candidates
60
+ end
61
+
62
+ # Primary agent responds if available, otherwise first candidate
63
+ def strategy_primary_first(candidates)
64
+ primary_id = @policy_resolver.primary_agent_id
65
+ return candidates.take(1) if primary_id.blank?
66
+
67
+ primary = candidates.find { |agent| agent.id == primary_id }
68
+ primary ? [ primary ] : candidates.take(1)
69
+ end
70
+
71
+ # Rotate between agents using Rails cache for state
72
+ def strategy_round_robin(candidates)
73
+ return candidates.take(1) if candidates.size <= 1
74
+
75
+ topic_id = @context.dig("topic", "id")
76
+ return candidates.take(1) if topic_id.blank?
77
+
78
+ # Sort candidates by id for consistent ordering
79
+ sorted_candidates = candidates.sort_by(&:id)
80
+
81
+ # Get last responder from cache
82
+ cache_key = "orchestrator:round_robin:topic:#{topic_id}"
83
+ last_responder_id = Rails.cache.read(cache_key)
84
+
85
+ # Find next agent in rotation
86
+ selected = if last_responder_id.present?
87
+ find_next_in_rotation(sorted_candidates, last_responder_id)
88
+ else
89
+ sorted_candidates.first
90
+ end
91
+
92
+ # Store current responder for next rotation
93
+ Rails.cache.write(cache_key, selected.id, expires_in: 24.hours)
94
+
95
+ [ selected ]
96
+ end
97
+
98
+ def find_next_in_rotation(candidates, last_responder_id)
99
+ last_index = candidates.find_index { |a| a.id == last_responder_id }
100
+
101
+ if last_index.nil?
102
+ # Last responder not in current candidates, start from beginning
103
+ candidates.first
104
+ else
105
+ # Get next agent, wrapping around to beginning
106
+ next_index = (last_index + 1) % candidates.size
107
+ candidates[next_index]
108
+ end
109
+ end
110
+
111
+ # Select agent based on relevance/expertise scoring
112
+ # Returns highest scoring agent if above threshold, otherwise empty
113
+ def strategy_bid(candidates)
114
+ return [] if candidates.empty?
115
+
116
+ threshold = @policy_resolver.confidence_threshold || DEFAULT_CONFIDENCE_THRESHOLD
117
+ message_text = extract_message_text
118
+
119
+ # Score each candidate
120
+ scored = candidates.map do |agent|
121
+ score = calculate_relevance_score(agent, message_text)
122
+ { agent: agent, score: score }
123
+ end
124
+
125
+ # Sort by score descending
126
+ scored.sort_by! { |s| -s[:score] }
127
+
128
+ # Return highest scoring agent if above threshold
129
+ best = scored.first
130
+ if best[:score] >= threshold
131
+ [ best[:agent] ]
132
+ else
133
+ # No agent meets threshold - return empty or fallback to first
134
+ # Based on policy, could return first candidate as fallback
135
+ @policy_resolver.bid_fallback_enabled? ? [ candidates.first ] : []
136
+ end
137
+ end
138
+
139
+ # Calculate relevance score for an agent (0.0 - 1.0)
140
+ def calculate_relevance_score(agent, message_text)
141
+ return 0.0 if message_text.blank?
142
+
143
+ scores = []
144
+
145
+ # 1. Keyword matching with agent's expertise/system_prompt
146
+ keyword_score = calculate_keyword_score(agent, message_text)
147
+ scores << keyword_score * 0.5 # 50% weight
148
+
149
+ # 2. Recent conversation participation
150
+ participation_score = calculate_participation_score(agent)
151
+ scores << participation_score * 0.3 # 30% weight
152
+
153
+ # 3. Agent specialty tags (if defined in metadata)
154
+ specialty_score = calculate_specialty_score(agent, message_text)
155
+ scores << specialty_score * 0.2 # 20% weight
156
+
157
+ scores.sum
158
+ end
159
+
160
+ def extract_message_text
161
+ # Extract text from the triggering comment
162
+ @context.dig("comment", "content") ||
163
+ @context.dig("comment", "body") ||
164
+ @context.dig("message", "content") ||
165
+ ""
166
+ end
167
+
168
+ # Score based on keyword overlap between message and agent's expertise
169
+ def calculate_keyword_score(agent, message_text)
170
+ expertise_text = extract_expertise_text(agent)
171
+ return 0.0 if expertise_text.blank?
172
+
173
+ message_words = tokenize(message_text.downcase)
174
+ expertise_words = tokenize(expertise_text.downcase)
175
+
176
+ return 0.0 if message_words.empty? || expertise_words.empty?
177
+
178
+ # Calculate Jaccard-like similarity
179
+ common_words = message_words & expertise_words
180
+ union_size = (message_words + expertise_words).uniq.size
181
+
182
+ return 0.0 if union_size.zero?
183
+
184
+ # Weighted by how many message words appear in expertise
185
+ # (more relevant if agent knows about what user is asking)
186
+ message_coverage = common_words.size.to_f / message_words.size
187
+ expertise_density = common_words.size.to_f / [ expertise_words.size, 50 ].min
188
+
189
+ (message_coverage * 0.7 + expertise_density * 0.3).clamp(0.0, 1.0)
190
+ end
191
+
192
+ def extract_expertise_text(agent)
193
+ texts = []
194
+
195
+ # From agent's system_prompt (primary source for AI agents)
196
+ texts << agent.system_prompt if agent.respond_to?(:system_prompt) && agent.system_prompt.present?
197
+
198
+ # From AI agent profile (for nested ai_agent association)
199
+ if agent.respond_to?(:ai_agent) && agent.ai_agent.present?
200
+ texts << agent.ai_agent.system_prompt if agent.ai_agent.respond_to?(:system_prompt)
201
+ texts << agent.ai_agent.description if agent.ai_agent.respond_to?(:description)
202
+ end
203
+
204
+ # From name (can indicate specialty)
205
+ texts << agent.name if agent.respond_to?(:name)
206
+
207
+ texts.compact.join(" ")
208
+ end
209
+
210
+ # Score based on recent participation in this topic
211
+ def calculate_participation_score(agent)
212
+ topic_id = @context.dig("topic", "id")
213
+ return 0.0 if topic_id.blank?
214
+
215
+ # Check cache for recent comments by this agent in topic
216
+ cache_key = "orchestrator:participation:topic:#{topic_id}:agent:#{agent.id}"
217
+ cached = Rails.cache.read(cache_key)
218
+ return cached if cached.present?
219
+
220
+ # Query recent comments (last 24 hours)
221
+ recent_count = Comment
222
+ .where(topic_id: topic_id, user_id: agent.id)
223
+ .where("created_at > ?", 24.hours.ago)
224
+ .count
225
+
226
+ # Normalize: 0 comments = 0, 5+ comments = 1.0
227
+ score = [ recent_count / 5.0, 1.0 ].min
228
+
229
+ Rails.cache.write(cache_key, score, expires_in: 5.minutes)
230
+ score
231
+ end
232
+
233
+ # Score based on specialty tags matching message content
234
+ def calculate_specialty_score(agent, message_text)
235
+ return 0.0 unless agent.respond_to?(:metadata)
236
+
237
+ tags = agent.metadata&.dig("specialty_tags") || []
238
+ return 0.0 if tags.empty?
239
+
240
+ message_lower = message_text.downcase
241
+ matched = tags.count { |tag| message_lower.include?(tag.to_s.downcase) }
242
+
243
+ [ matched.to_f / tags.size, 1.0 ].min
244
+ end
245
+
246
+ # Simple tokenization for keyword matching
247
+ def tokenize(text)
248
+ # Remove punctuation, split by whitespace, filter short words
249
+ text
250
+ .gsub(/[^\p{L}\p{N}\s]/, " ")
251
+ .split(/\s+/)
252
+ .reject { |w| w.length < 3 }
253
+ .uniq
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Orchestration
5
+ # LoopBreaker detects and prevents infinite loops in agent orchestration.
6
+ #
7
+ # Detects:
8
+ # - Ping-pong: Same two agents messaging back and forth too many times
9
+ # - Creative retry: Too many tasks created on same creative in time window
10
+ # - Task timeout: Single task running too long
11
+ # - Token spike: Abnormal token usage in short time window
12
+ #
13
+ # Usage:
14
+ # breaker = LoopBreaker.new(context, policy_resolver: resolver)
15
+ # result = breaker.check
16
+ # if result.should_break?
17
+ # # Handle loop detection - escalate to human
18
+ # end
19
+ #
20
+ class LoopBreaker
21
+ CACHE_PREFIX = "loop_breaker"
22
+ CACHE_EXPIRY = 1.hour
23
+
24
+ Result = Data.define(:should_break, :reason, :details) do
25
+ def should_break?
26
+ should_break
27
+ end
28
+
29
+ def safe?
30
+ !should_break
31
+ end
32
+ end
33
+
34
+ def initialize(context, policy_resolver: nil)
35
+ @context = context
36
+ @policy_resolver = policy_resolver || PolicyResolver.new(context)
37
+ @config = @policy_resolver.loop_breaker_config
38
+ end
39
+
40
+ # Main entry point - checks all loop conditions
41
+ def check
42
+ return Result.new(should_break: false, reason: nil, details: {}) unless enabled?
43
+
44
+ # Check each condition
45
+ checks = [
46
+ check_ping_pong,
47
+ check_creative_retry,
48
+ check_task_timeout,
49
+ check_token_spike
50
+ ]
51
+
52
+ # Return first violation found
53
+ violation = checks.find(&:should_break?)
54
+ violation || Result.new(should_break: false, reason: nil, details: {})
55
+ end
56
+
57
+ # Record an interaction for ping-pong detection
58
+ def record_interaction(from_agent_id, to_agent_id, creative_id)
59
+ key = "#{CACHE_PREFIX}:ping_pong_history:#{creative_id}"
60
+ interactions = Rails.cache.read(key) || []
61
+ interactions << { at: Time.current.to_i, from: from_agent_id, to: to_agent_id }
62
+
63
+ # Keep only recent interactions (within window)
64
+ window_start = (Time.current - ping_pong_window).to_i
65
+ interactions = interactions.select { |i| i[:at] >= window_start }
66
+
67
+ Rails.cache.write(key, interactions, expires_in: CACHE_EXPIRY)
68
+ end
69
+
70
+ # Record a task creation for creative retry detection
71
+ def record_task(creative_id, agent_id)
72
+ key = creative_tasks_key(creative_id)
73
+ tasks = Rails.cache.read(key) || []
74
+ tasks << { at: Time.current.to_i, agent_id: agent_id }
75
+
76
+ # Keep only tasks within window
77
+ window_start = (Time.current - creative_retry_window).to_i
78
+ tasks = tasks.select { |t| t[:at] >= window_start }
79
+
80
+ Rails.cache.write(key, tasks, expires_in: CACHE_EXPIRY)
81
+ end
82
+
83
+ # Record token usage for spike detection
84
+ def record_tokens(agent_id, tokens_used)
85
+ key = token_usage_key(agent_id)
86
+ usage = Rails.cache.read(key) || []
87
+ usage << { at: Time.current.to_i, tokens: tokens_used }
88
+
89
+ # Keep only usage within window
90
+ window_start = (Time.current - token_spike_window).to_i
91
+ usage = usage.select { |u| u[:at] >= window_start }
92
+
93
+ Rails.cache.write(key, usage, expires_in: CACHE_EXPIRY)
94
+ end
95
+
96
+ # Reset tracking for a creative (after human intervention)
97
+ def reset_for_creative(creative_id)
98
+ # Clear all related cache keys
99
+ Rails.cache.delete_matched("#{CACHE_PREFIX}:creative:#{creative_id}:*")
100
+ Rails.cache.delete_matched("#{CACHE_PREFIX}:ping_pong:*:#{creative_id}")
101
+ end
102
+
103
+ private
104
+
105
+ def enabled?
106
+ @config["enabled"] == true
107
+ end
108
+
109
+ # Detect ping-pong between agents
110
+ def check_ping_pong
111
+ creative_id = @context.dig("creative", "id")
112
+ return safe_result unless creative_id
113
+
114
+ # Check the ping-pong tracking key for this creative
115
+ key = "#{CACHE_PREFIX}:ping_pong_history:#{creative_id}"
116
+ interactions = Rails.cache.read(key) || []
117
+
118
+ # Count exchanges for each agent pair
119
+ pair_exchanges = count_pair_exchanges(interactions)
120
+
121
+ pair_exchanges.each do |pair, count|
122
+ if count >= ping_pong_threshold
123
+ return Result.new(
124
+ should_break: true,
125
+ reason: :ping_pong,
126
+ details: {
127
+ agents: pair,
128
+ creative_id: creative_id,
129
+ exchange_count: count,
130
+ threshold: ping_pong_threshold
131
+ }
132
+ )
133
+ end
134
+ end
135
+
136
+ safe_result
137
+ end
138
+
139
+ # Detect too many tasks on same creative
140
+ def check_creative_retry
141
+ creative_id = @context.dig("creative", "id")
142
+ return safe_result unless creative_id
143
+
144
+ key = creative_tasks_key(creative_id)
145
+ tasks = Rails.cache.read(key) || []
146
+
147
+ # Filter to window
148
+ window_start = (Time.current - creative_retry_window).to_i
149
+ recent_tasks = tasks.select { |t| t[:at] >= window_start }
150
+
151
+ if recent_tasks.size >= creative_retry_threshold
152
+ return Result.new(
153
+ should_break: true,
154
+ reason: :creative_retry_exceeded,
155
+ details: {
156
+ creative_id: creative_id,
157
+ task_count: recent_tasks.size,
158
+ threshold: creative_retry_threshold,
159
+ window_minutes: @config["creative_retry_window_minutes"]
160
+ }
161
+ )
162
+ end
163
+
164
+ safe_result
165
+ end
166
+
167
+ # Detect tasks running too long
168
+ def check_task_timeout
169
+ # Find running tasks for this context (using topic_id as Task doesn't have creative_id)
170
+ topic_id = @context.dig("topic", "id")
171
+
172
+ return safe_result unless topic_id
173
+
174
+ timeout_threshold = task_timeout_minutes.minutes.ago
175
+
176
+ timed_out_task = Task.where(status: "running", topic_id: topic_id)
177
+ .where("created_at < ?", timeout_threshold)
178
+ .first
179
+
180
+ if timed_out_task
181
+ return Result.new(
182
+ should_break: true,
183
+ reason: :task_timeout,
184
+ details: {
185
+ task_id: timed_out_task.id,
186
+ agent_id: timed_out_task.agent_id,
187
+ started_at: timed_out_task.created_at,
188
+ timeout_minutes: task_timeout_minutes
189
+ }
190
+ )
191
+ end
192
+
193
+ safe_result
194
+ end
195
+
196
+ # Detect token usage spikes
197
+ def check_token_spike
198
+ # Check for current agent or all agents in creative
199
+ agent_id = @context.dig("agent", "id") || @context.dig("user", "id")
200
+ return safe_result unless agent_id
201
+
202
+ key = token_usage_key(agent_id)
203
+ usage = Rails.cache.read(key) || []
204
+
205
+ # Sum tokens in window
206
+ window_start = (Time.current - token_spike_window).to_i
207
+ recent_usage = usage.select { |u| u[:at] >= window_start }
208
+ total_tokens = recent_usage.sum { |u| u[:tokens] }
209
+
210
+ if total_tokens >= token_spike_threshold
211
+ return Result.new(
212
+ should_break: true,
213
+ reason: :token_spike,
214
+ details: {
215
+ agent_id: agent_id,
216
+ tokens_used: total_tokens,
217
+ threshold: token_spike_threshold,
218
+ window_minutes: @config["token_spike_window_minutes"]
219
+ }
220
+ )
221
+ end
222
+
223
+ safe_result
224
+ end
225
+
226
+ # Count exchanges for each pair of agents
227
+ def count_pair_exchanges(interactions)
228
+ return {} if interactions.size < 2
229
+
230
+ pair_counts = Hash.new(0)
231
+ sorted = interactions.sort_by { |i| i[:at] }
232
+
233
+ sorted.each_cons(2) do |prev, curr|
234
+ # Count as exchange if direction reversed (A→B then B→A)
235
+ if prev[:from] == curr[:to] && prev[:to] == curr[:from]
236
+ # Normalize pair key (smaller id first)
237
+ pair = [ prev[:from], prev[:to] ].sort
238
+ pair_counts[pair] += 1
239
+ end
240
+ end
241
+
242
+ pair_counts
243
+ end
244
+
245
+ def safe_result
246
+ Result.new(should_break: false, reason: nil, details: {})
247
+ end
248
+
249
+ # Cache keys
250
+ def ping_pong_key(agent1_id, agent2_id, creative_id)
251
+ # Normalize key so A-B and B-A use same key
252
+ sorted = [ agent1_id, agent2_id ].sort
253
+ "#{CACHE_PREFIX}:ping_pong:#{sorted[0]}-#{sorted[1]}:#{creative_id}"
254
+ end
255
+
256
+ def creative_tasks_key(creative_id)
257
+ "#{CACHE_PREFIX}:creative:#{creative_id}:tasks"
258
+ end
259
+
260
+ def token_usage_key(agent_id)
261
+ "#{CACHE_PREFIX}:agent:#{agent_id}:tokens:#{Date.current}"
262
+ end
263
+
264
+ # Config accessors
265
+ def ping_pong_threshold
266
+ @config["ping_pong_threshold"] || 5
267
+ end
268
+
269
+ def ping_pong_window
270
+ 30.minutes # Fixed window for ping-pong detection
271
+ end
272
+
273
+ def creative_retry_threshold
274
+ @config["creative_retry_threshold"] || 10
275
+ end
276
+
277
+ def creative_retry_window
278
+ (@config["creative_retry_window_minutes"] || 30).minutes
279
+ end
280
+
281
+ def task_timeout_minutes
282
+ @config["task_timeout_minutes"] || 60
283
+ end
284
+
285
+ def token_spike_threshold
286
+ @config["token_spike_threshold"] || 50_000
287
+ end
288
+
289
+ def token_spike_window
290
+ (@config["token_spike_window_minutes"] || 10).minutes
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Orchestration
5
+ # Matcher determines which AI agents are qualified to respond to an event.
6
+ #
7
+ # Matching strategies (in priority order):
8
+ # 1. Mention-based: If a user is @mentioned, route exclusively to that user
9
+ # - If mentioned user is AI agent → route to that agent only
10
+ # - If mentioned user is human → no AI agents respond
11
+ # 2. Expression-based: Evaluate each agent's routing_expression (Liquid)
12
+ #
13
+ # Permission checks:
14
+ # - All agents need feedback permission on the creative to respond
15
+ # - searchable only affects discoverability, not response permission
16
+ #
17
+ class Matcher
18
+ def initialize(context)
19
+ @context = context
20
+ end
21
+
22
+ # Returns Array of User (AI agents) that are qualified to respond
23
+ def match
24
+ # Priority 1: Mention-based routing (exclusive)
25
+ mentioned_result = match_by_mention
26
+ return mentioned_result unless mentioned_result.nil?
27
+
28
+ # Priority 2: Liquid expression routing (fallback)
29
+ match_by_expression
30
+ end
31
+
32
+ private
33
+
34
+ # Returns Array of agents if mention found, nil if no mention
35
+ # When mention IS found, this is exclusive routing
36
+ def match_by_mention
37
+ mentioned_user_data = @context.dig("chat", "mentioned_user")
38
+ return nil unless mentioned_user_data && mentioned_user_data["id"]
39
+
40
+ mentioned_user = User.find_by(id: mentioned_user_data["id"])
41
+ return nil unless mentioned_user
42
+
43
+ # Mention found — exclusive routing
44
+ # If mentioned user is not an AI agent, no AI agents should receive it
45
+ return [] unless mentioned_user.ai_user?
46
+
47
+ # Permission check for mentioned AI agent
48
+ return [] unless has_creative_permission?(mentioned_user)
49
+
50
+ [ mentioned_user ]
51
+ end
52
+
53
+ def match_by_expression
54
+ # Find all AI agents with routing expressions
55
+ # Order by id for consistent ordering (important for round_robin strategy)
56
+ agents = User.where.not(llm_vendor: nil).where.not(routing_expression: [ nil, "" ]).order(:id)
57
+
58
+ agents.select do |agent|
59
+ next false unless has_creative_permission?(agent)
60
+
61
+ evaluate_routing_expression(agent)
62
+ end
63
+ end
64
+
65
+ def evaluate_routing_expression(agent)
66
+ expression = agent.routing_expression.strip
67
+
68
+ # Wrap in if block if not already a Liquid tag
69
+ unless expression.start_with?("{%")
70
+ expression = "{% if #{expression} %}true{% endif %}"
71
+ end
72
+
73
+ # Add agent to context for self-reference
74
+ agent_context = @context.merge("agent" => agent.as_json(only: [ :id, :name, :email ]))
75
+
76
+ template = Liquid::Template.parse(expression)
77
+ result = template.render(agent_context)
78
+
79
+ result.strip == "true"
80
+ rescue StandardError => e
81
+ Rails.logger.error("[Matcher] Routing error for agent #{agent.id}: #{e.message}")
82
+ false
83
+ end
84
+
85
+ def has_creative_permission?(agent)
86
+ # All agents need feedback permission on the creative to respond
87
+ # searchable only affects discoverability, not response permission
88
+ creative_id = @context.dig("creative", "id") || @context.dig(:creative, :id)
89
+ return false unless creative_id
90
+
91
+ creative = Creative.find_by(id: creative_id)
92
+ return false unless creative
93
+
94
+ creative.has_permission?(agent, :feedback)
95
+ end
96
+ end
97
+ end
98
+ end