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.
- checksums.yaml +4 -4
- data/app/channels/collavre/comments_presence_channel.rb +6 -3
- data/app/controllers/collavre/admin/orchestration_controller.rb +177 -0
- data/app/controllers/collavre/comments_controller.rb +10 -1
- data/app/javascript/controllers/comments/presence_controller.js +5 -1
- data/app/jobs/collavre/ai_agent_job.rb +55 -4
- data/app/jobs/collavre/cron_action_job.rb +53 -0
- data/app/jobs/collavre/stuck_detector_job.rb +34 -0
- data/app/models/collavre/comment.rb +15 -15
- data/app/models/collavre/creative.rb +1 -1
- data/app/models/collavre/orchestrator_policy.rb +124 -0
- data/app/models/collavre/task.rb +3 -0
- data/app/models/collavre/user.rb +1 -1
- data/app/services/collavre/ai_agent_service.rb +32 -1
- data/app/services/collavre/ai_client.rb +4 -3
- data/app/services/collavre/comments/command_processor.rb +4 -1
- data/app/services/collavre/comments/topic_command.rb +101 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +201 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +142 -0
- data/app/services/collavre/orchestration/arbiter.rb +257 -0
- data/app/services/collavre/orchestration/loop_breaker.rb +294 -0
- data/app/services/collavre/orchestration/matcher.rb +98 -0
- data/app/services/collavre/orchestration/policy_resolver.rb +170 -0
- data/app/services/collavre/orchestration/resource_tracker.rb +135 -0
- data/app/services/collavre/orchestration/scheduler.rb +145 -0
- data/app/services/collavre/orchestration/self_reflection_evaluator.rb +231 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +303 -0
- data/app/services/collavre/system_events/context_builder.rb +34 -7
- data/app/services/collavre/system_events/dispatcher.rb +2 -7
- data/app/services/collavre/tools/cron_cancel_service.rb +49 -0
- data/app/services/collavre/tools/cron_create_service.rb +73 -0
- data/app/services/collavre/tools/cron_list_service.rb +65 -0
- data/app/services/collavre/tools/cron_update_service.rb +82 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/orchestration/show.html.erb +66 -0
- data/app/views/collavre/creatives/index.html.erb +1 -1
- data/config/locales/ai_agent.en.yml +21 -0
- data/config/locales/ai_agent.ko.yml +21 -0
- data/config/locales/comments.en.yml +8 -0
- data/config/locales/comments.ko.yml +8 -0
- data/config/routes.rb +5 -1
- data/db/migrate/20260206005035_create_orchestrator_policies.rb +32 -0
- data/db/migrate/20260206094509_add_retry_count_to_tasks.rb +5 -0
- data/db/migrate/20260206100000_add_topic_id_to_tasks.rb +6 -0
- data/lib/collavre/version.rb +1 -1
- metadata +24 -13
- data/app/controllers/collavre/github_auth_controller.rb +0 -25
- data/app/models/collavre/github_account.rb +0 -10
- data/app/models/collavre/github_repository_link.rb +0 -19
- data/app/services/collavre/github/client.rb +0 -112
- data/app/services/collavre/github/pull_request_analyzer.rb +0 -280
- data/app/services/collavre/github/pull_request_processor.rb +0 -181
- data/app/services/collavre/github/webhook_provisioner.rb +0 -130
- data/app/services/collavre/system_events/router.rb +0 -96
- data/app/views/collavre/creatives/_github_integration_modal.html.erb +0 -77
- data/db/migrate/20250925000000_create_github_integrations.rb +0 -26
- data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +0 -29
- 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
|