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
|
@@ -32,11 +32,19 @@ module Collavre
|
|
|
32
32
|
rendering_context["creative"] = creative.as_json if creative
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Add agent collaboration context
|
|
36
|
+
agent_context = build_agent_context(creative)
|
|
37
|
+
rendering_context.merge!(agent_context)
|
|
38
|
+
|
|
35
39
|
rendered_system_prompt = AiSystemPromptRenderer.new(
|
|
36
40
|
template: @agent.system_prompt,
|
|
37
41
|
context: rendering_context
|
|
38
42
|
).render
|
|
39
43
|
|
|
44
|
+
# Append collaboration guide to system prompt
|
|
45
|
+
collaboration_prompt = build_collaboration_prompt(creative)
|
|
46
|
+
rendered_system_prompt = "#{rendered_system_prompt}\n\n#{collaboration_prompt}" if collaboration_prompt.present?
|
|
47
|
+
|
|
40
48
|
# Create a placeholder comment to stream into
|
|
41
49
|
target_comment_id = @context.dig("comment", "id")
|
|
42
50
|
@original_comment = target_comment_id ? Comment.find_by(id: target_comment_id) : nil
|
|
@@ -109,6 +117,8 @@ module Collavre
|
|
|
109
117
|
|
|
110
118
|
# Broadcast "idle" status
|
|
111
119
|
broadcast_agent_status("idle")
|
|
120
|
+
|
|
121
|
+
@response_content
|
|
112
122
|
end
|
|
113
123
|
rescue ApprovalPendingError => e
|
|
114
124
|
handle_approval_pending(e)
|
|
@@ -154,7 +164,8 @@ module Collavre
|
|
|
154
164
|
agent_id: @agent.id,
|
|
155
165
|
agent_name: @agent.display_name,
|
|
156
166
|
task_id: @task.id,
|
|
157
|
-
content: content
|
|
167
|
+
content: content,
|
|
168
|
+
source_creative_id: @creative.id
|
|
158
169
|
)
|
|
159
170
|
end
|
|
160
171
|
|
|
@@ -236,6 +247,26 @@ module Collavre
|
|
|
236
247
|
.update_all(comment_id: to_comment.id)
|
|
237
248
|
end
|
|
238
249
|
|
|
250
|
+
def build_agent_context(creative)
|
|
251
|
+
return {} unless creative
|
|
252
|
+
|
|
253
|
+
Orchestration::AgentContextBuilder.new(
|
|
254
|
+
agent: @agent,
|
|
255
|
+
creative: creative,
|
|
256
|
+
sender: @context["sender"]
|
|
257
|
+
).build
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def build_collaboration_prompt(creative)
|
|
261
|
+
return nil unless creative
|
|
262
|
+
|
|
263
|
+
Orchestration::AgentContextBuilder.new(
|
|
264
|
+
agent: @agent,
|
|
265
|
+
creative: creative,
|
|
266
|
+
sender: @context["sender"]
|
|
267
|
+
).to_collaboration_prompt
|
|
268
|
+
end
|
|
269
|
+
|
|
239
270
|
def handle_approval_pending(error)
|
|
240
271
|
# Clean up placeholder comment if exists
|
|
241
272
|
@reply_comment&.destroy! if @reply_comment&.content == Comment::STREAMING_PLACEHOLDER_CONTENT
|
|
@@ -83,10 +83,11 @@ module Collavre
|
|
|
83
83
|
rescue CancelledError
|
|
84
84
|
raise # Re-raise cancellation errors without catching them
|
|
85
85
|
rescue StandardError => e
|
|
86
|
-
error_message = e.message
|
|
87
|
-
Rails.logger.error "AI Client error: #{
|
|
86
|
+
error_message = "[#{e.class.name}] #{e.message}"
|
|
87
|
+
Rails.logger.error "AI Client error: #{error_message}"
|
|
88
|
+
Rails.logger.error "Partial response length: #{response_content.length} chars" if response_content.present?
|
|
88
89
|
Rails.logger.debug e.backtrace.join("\n")
|
|
89
|
-
yield "AI Error: #{
|
|
90
|
+
yield "\n\n⚠️ AI Error: #{error_message}" if block_given?
|
|
90
91
|
nil
|
|
91
92
|
ensure
|
|
92
93
|
log_interaction(
|
|
@@ -26,7 +26,10 @@ module Collavre
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def static_commands
|
|
29
|
-
[
|
|
29
|
+
[
|
|
30
|
+
Collavre::Comments::CalendarCommand.new(comment: comment, user: user),
|
|
31
|
+
Collavre::Comments::TopicCommand.new(comment: comment, user: user)
|
|
32
|
+
]
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
def mcp_commands
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Comments
|
|
3
|
+
class TopicCommand
|
|
4
|
+
def initialize(comment:, user:, url_helpers: Collavre::Engine.routes.url_helpers)
|
|
5
|
+
@comment = comment
|
|
6
|
+
@user = user
|
|
7
|
+
@creative = comment.creative.effective_origin
|
|
8
|
+
@url_helpers = url_helpers
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
return unless topic_command?
|
|
13
|
+
|
|
14
|
+
create_topic
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
Rails.logger.error("Topic command failed: #{e.message}")
|
|
17
|
+
e.message
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :comment, :user, :creative, :url_helpers
|
|
23
|
+
|
|
24
|
+
# /topic "topic name" @agent_name
|
|
25
|
+
# /topic "topic name"
|
|
26
|
+
COMMAND_PATTERN = /\A\/topic\b/i.freeze
|
|
27
|
+
|
|
28
|
+
def topic_command?
|
|
29
|
+
comment.content.to_s.strip.match?(COMMAND_PATTERN)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parsed_args
|
|
33
|
+
@parsed_args ||= begin
|
|
34
|
+
content = comment.content.to_s.strip
|
|
35
|
+
|
|
36
|
+
# Extract topic name in quotes
|
|
37
|
+
name_match = content.match(/[\u201c\u201d""]([^"\u201c\u201d""]+)[\u201c\u201d""]|"([^"]+)"/)
|
|
38
|
+
topic_name = name_match ? (name_match[1] || name_match[2]) : nil
|
|
39
|
+
|
|
40
|
+
return if topic_name.blank?
|
|
41
|
+
|
|
42
|
+
{ name: topic_name }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def create_topic
|
|
47
|
+
data = parsed_args
|
|
48
|
+
return I18n.t("collavre.comments.topic_command.missing_name") if data.blank?
|
|
49
|
+
|
|
50
|
+
# Find primary agent from @mentions using the same parsing as chat
|
|
51
|
+
primary_agent = comment.mentioned_users.find(&:ai_user?)
|
|
52
|
+
|
|
53
|
+
# Find existing topic or create new one
|
|
54
|
+
existing_topic = Topic.find_by(creative: creative, name: data[:name])
|
|
55
|
+
|
|
56
|
+
if existing_topic
|
|
57
|
+
if primary_agent
|
|
58
|
+
set_primary_agent(existing_topic, primary_agent)
|
|
59
|
+
I18n.t("collavre.comments.topic_command.updated_agent",
|
|
60
|
+
name: existing_topic.name,
|
|
61
|
+
agent: primary_agent.name)
|
|
62
|
+
else
|
|
63
|
+
I18n.t("collavre.comments.topic_command.already_exists",
|
|
64
|
+
name: existing_topic.name)
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
topic = Topic.create!(
|
|
68
|
+
creative: creative,
|
|
69
|
+
user: user,
|
|
70
|
+
name: data[:name]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if primary_agent
|
|
74
|
+
set_primary_agent(topic, primary_agent)
|
|
75
|
+
I18n.t("collavre.comments.topic_command.created_with_agent",
|
|
76
|
+
name: topic.name,
|
|
77
|
+
agent: primary_agent.name)
|
|
78
|
+
else
|
|
79
|
+
I18n.t("collavre.comments.topic_command.created", name: topic.name)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def set_primary_agent(topic, agent)
|
|
85
|
+
policy = OrchestratorPolicy.find_or_initialize_by(
|
|
86
|
+
policy_type: "arbitration",
|
|
87
|
+
scope_type: "Topic",
|
|
88
|
+
scope_id: topic.id
|
|
89
|
+
)
|
|
90
|
+
policy.update!(
|
|
91
|
+
config: {
|
|
92
|
+
"strategy" => "primary_first",
|
|
93
|
+
"primary_agent_id" => agent.id
|
|
94
|
+
},
|
|
95
|
+
priority: 10,
|
|
96
|
+
enabled: true
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Orchestration
|
|
3
|
+
# Builds context for AI agents including:
|
|
4
|
+
# - Agent identity
|
|
5
|
+
# - Available collaborators from Creative permissions
|
|
6
|
+
# - Organization hierarchy derived from permission levels
|
|
7
|
+
# - Sender context for A2A communication
|
|
8
|
+
#
|
|
9
|
+
# Permission → Role mapping:
|
|
10
|
+
# - admin: escalation targets (supervisors)
|
|
11
|
+
# - write: peers (collaborators)
|
|
12
|
+
# - feedback: reviewers
|
|
13
|
+
# - read: information sources only
|
|
14
|
+
class AgentContextBuilder
|
|
15
|
+
PERMISSION_ROLES = {
|
|
16
|
+
"admin" => "escalation", # Can escalate issues to
|
|
17
|
+
"write" => "collaborator", # Can collaborate with
|
|
18
|
+
"feedback" => "reviewer", # Can request reviews from
|
|
19
|
+
"read" => "reference" # Can request information from
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def initialize(agent:, creative:, sender: nil)
|
|
23
|
+
@agent = agent
|
|
24
|
+
@creative = creative
|
|
25
|
+
@sender = sender
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def a2a_request?
|
|
29
|
+
@sender && @sender["is_ai"] == true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns hash suitable for Liquid template rendering
|
|
33
|
+
def build
|
|
34
|
+
result = {
|
|
35
|
+
"agent" => build_agent_identity,
|
|
36
|
+
"collaborators" => build_collaborators,
|
|
37
|
+
"collaboration_guide" => build_collaboration_guide,
|
|
38
|
+
"is_a2a" => a2a_request?
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
result["sender"] = @sender if @sender
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Generates markdown-formatted collaboration section for system prompt
|
|
46
|
+
def to_collaboration_prompt
|
|
47
|
+
sections = []
|
|
48
|
+
|
|
49
|
+
# Add A2A request context if this is an agent-to-agent call
|
|
50
|
+
if a2a_request?
|
|
51
|
+
sections << I18n.t("collavre.ai_agent.a2a.request_header")
|
|
52
|
+
sections << ""
|
|
53
|
+
sections << I18n.t("collavre.ai_agent.a2a.request_description",
|
|
54
|
+
sender_name: @sender["name"], sender_type: @sender["type"])
|
|
55
|
+
sections << I18n.t("collavre.ai_agent.a2a.focus_instruction")
|
|
56
|
+
sections << I18n.t("collavre.ai_agent.a2a.completion_instruction")
|
|
57
|
+
sections << I18n.t("collavre.ai_agent.a2a.followup_instruction")
|
|
58
|
+
sections << ""
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
collaborators = build_collaborators
|
|
62
|
+
return sections.join("\n") if collaborators.empty?
|
|
63
|
+
|
|
64
|
+
sections << I18n.t("collavre.ai_agent.collaboration.header")
|
|
65
|
+
sections << ""
|
|
66
|
+
|
|
67
|
+
# Group by role
|
|
68
|
+
by_role = collaborators.group_by { |c| c["role"] }
|
|
69
|
+
|
|
70
|
+
if by_role["escalation"]&.any?
|
|
71
|
+
sections << I18n.t("collavre.ai_agent.collaboration.escalation_header")
|
|
72
|
+
by_role["escalation"].each do |c|
|
|
73
|
+
sections << "- @#{c['name']}: #{c['description']}"
|
|
74
|
+
end
|
|
75
|
+
sections << ""
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if by_role["collaborator"]&.any?
|
|
79
|
+
sections << I18n.t("collavre.ai_agent.collaboration.collaborator_header")
|
|
80
|
+
by_role["collaborator"].each do |c|
|
|
81
|
+
sections << "- @#{c['name']}: #{c['description']}"
|
|
82
|
+
end
|
|
83
|
+
sections << ""
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if by_role["reviewer"]&.any?
|
|
87
|
+
sections << I18n.t("collavre.ai_agent.collaboration.reviewer_header")
|
|
88
|
+
by_role["reviewer"].each do |c|
|
|
89
|
+
sections << "- @#{c['name']}: #{c['description']}"
|
|
90
|
+
end
|
|
91
|
+
sections << ""
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if by_role["reference"]&.any?
|
|
95
|
+
sections << I18n.t("collavre.ai_agent.collaboration.reference_header")
|
|
96
|
+
by_role["reference"].each do |c|
|
|
97
|
+
sections << "- @#{c['name']}: #{c['description']}"
|
|
98
|
+
end
|
|
99
|
+
sections << ""
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
sections << I18n.t("collavre.ai_agent.collaboration.rules_header")
|
|
103
|
+
sections << I18n.t("collavre.ai_agent.collaboration.mention_rule")
|
|
104
|
+
sections << I18n.t("collavre.ai_agent.collaboration.confidence_rule")
|
|
105
|
+
sections << I18n.t("collavre.ai_agent.collaboration.escalation_rule")
|
|
106
|
+
sections << I18n.t("collavre.ai_agent.collaboration.review_rule")
|
|
107
|
+
sections << ""
|
|
108
|
+
|
|
109
|
+
sections.join("\n")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def build_agent_identity
|
|
115
|
+
{
|
|
116
|
+
"id" => @agent.id,
|
|
117
|
+
"name" => @agent.name,
|
|
118
|
+
"display_name" => @agent.display_name,
|
|
119
|
+
"type" => extract_agent_type
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def extract_agent_type
|
|
124
|
+
# Extract agent type from system_prompt or default
|
|
125
|
+
prompt = @agent.system_prompt.to_s.downcase
|
|
126
|
+
case prompt
|
|
127
|
+
when /developer|개발/ then "developer"
|
|
128
|
+
when /pm|project.?manager|프로젝트/ then "pm"
|
|
129
|
+
when /qa|test|quality|테스트|품질/ then "qa"
|
|
130
|
+
when /research|조사|연구/ then "researcher"
|
|
131
|
+
when /market|마케팅/ then "marketer"
|
|
132
|
+
when /plan|기획/ then "planner"
|
|
133
|
+
else "agent"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_collaborators
|
|
138
|
+
return [] unless @creative
|
|
139
|
+
|
|
140
|
+
# Get all AI agents with access to this creative tree
|
|
141
|
+
ai_agents_with_access.map do |user, permission|
|
|
142
|
+
next if user.id == @agent.id # Skip self
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
"id" => user.id,
|
|
146
|
+
"name" => user.name,
|
|
147
|
+
"display_name" => user.display_name,
|
|
148
|
+
"role" => PERMISSION_ROLES[permission] || "reference",
|
|
149
|
+
"permission" => permission,
|
|
150
|
+
"description" => extract_agent_description(user)
|
|
151
|
+
}
|
|
152
|
+
end.compact
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def ai_agents_with_access
|
|
156
|
+
# Collect AI agents from creative and its ancestors
|
|
157
|
+
creatives_to_check = [ @creative ] + @creative.ancestors.to_a
|
|
158
|
+
|
|
159
|
+
agent_permissions = {}
|
|
160
|
+
|
|
161
|
+
creatives_to_check.each do |creative|
|
|
162
|
+
creative.creative_shares.includes(:user).each do |share|
|
|
163
|
+
user = share.user
|
|
164
|
+
next unless user&.ai_user?
|
|
165
|
+
next if share.permission == "no_access"
|
|
166
|
+
|
|
167
|
+
# Keep highest permission level
|
|
168
|
+
current = agent_permissions[user]
|
|
169
|
+
if current.nil? || permission_rank(share.permission) > permission_rank(current)
|
|
170
|
+
agent_permissions[user] = share.permission
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
agent_permissions.to_a
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def permission_rank(permission)
|
|
179
|
+
CreativeShare.permissions[permission.to_s] || 0
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def extract_agent_description(user)
|
|
183
|
+
prompt = user.system_prompt.to_s
|
|
184
|
+
# Extract first meaningful line or sentence
|
|
185
|
+
first_line = prompt.lines.find { |l| l.strip.present? && !l.start_with?("#") }
|
|
186
|
+
return "AI Agent" unless first_line
|
|
187
|
+
|
|
188
|
+
# Truncate to reasonable length
|
|
189
|
+
first_line.strip.truncate(100)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def build_collaboration_guide
|
|
193
|
+
{
|
|
194
|
+
"mention_format" => I18n.t("collavre.ai_agent.collaboration.mention_rule"),
|
|
195
|
+
"escalation_hint" => I18n.t("collavre.ai_agent.collaboration.escalation_rule"),
|
|
196
|
+
"review_hint" => I18n.t("collavre.ai_agent.collaboration.review_rule")
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Orchestration
|
|
5
|
+
# AgentOrchestrator is the single entry point for AI agent routing and scheduling.
|
|
6
|
+
# It coordinates the following components:
|
|
7
|
+
# - Matcher: determines which agents are qualified to respond
|
|
8
|
+
# - Arbiter: selects which agents will actually respond (floor control)
|
|
9
|
+
# - Scheduler: decides when to execute (resource management)
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# AgentOrchestrator.dispatch("comment_created", context)
|
|
13
|
+
#
|
|
14
|
+
class AgentOrchestrator
|
|
15
|
+
def self.dispatch(event_name, context)
|
|
16
|
+
new(event_name: event_name, context: context).dispatch
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.dequeue_next_for_topic(topic_id)
|
|
20
|
+
task = Task.queued_for_topic(topic_id).first
|
|
21
|
+
return unless task
|
|
22
|
+
|
|
23
|
+
updated = Task.where(id: task.id, status: "queued").update_all(status: "pending")
|
|
24
|
+
if updated > 0
|
|
25
|
+
task.reload
|
|
26
|
+
refresh_deferred_context!(task)
|
|
27
|
+
|
|
28
|
+
if task.status == "cancelled"
|
|
29
|
+
# refresh_deferred_context! cancelled this task (no eligible comment),
|
|
30
|
+
# try the next queued task for this topic.
|
|
31
|
+
dequeue_next_for_topic(topic_id)
|
|
32
|
+
else
|
|
33
|
+
AiAgentJob.perform_later(task)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Refresh trigger_event_payload so the deferred agent sees the latest
|
|
39
|
+
# conversation state instead of the stale snapshot from enqueue time.
|
|
40
|
+
# Skips AI agent's own comments to prevent self-response loops.
|
|
41
|
+
# Cancels the task if no eligible comment remains.
|
|
42
|
+
def self.refresh_deferred_context!(task)
|
|
43
|
+
context = task.trigger_event_payload
|
|
44
|
+
creative_id = context&.dig("creative", "id")
|
|
45
|
+
return unless creative_id && context&.key?("topic")
|
|
46
|
+
|
|
47
|
+
topic_id = context.dig("topic", "id")
|
|
48
|
+
scope = Comment
|
|
49
|
+
.where(creative_id: creative_id, topic_id: topic_id, private: false)
|
|
50
|
+
.where.not(user_id: task.agent_id)
|
|
51
|
+
.order(created_at: :desc)
|
|
52
|
+
latest_comment = scope.first
|
|
53
|
+
|
|
54
|
+
unless latest_comment
|
|
55
|
+
task.update!(status: "cancelled")
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
context["comment"] = {
|
|
60
|
+
"id" => latest_comment.id,
|
|
61
|
+
"content" => latest_comment.content,
|
|
62
|
+
"user_id" => latest_comment.user_id
|
|
63
|
+
}
|
|
64
|
+
context["chat"] = { "content" => latest_comment.content }
|
|
65
|
+
task.update!(trigger_event_payload: context)
|
|
66
|
+
end
|
|
67
|
+
private_class_method :refresh_deferred_context!
|
|
68
|
+
|
|
69
|
+
def initialize(event_name:, context:)
|
|
70
|
+
@event_name = event_name
|
|
71
|
+
# Build context ONCE here - no more duplicate builds
|
|
72
|
+
@context = SystemEvents::ContextBuilder.new(context).build
|
|
73
|
+
@context["event_name"] = event_name
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def dispatch
|
|
77
|
+
# Step 1: Find qualified agents (Matcher)
|
|
78
|
+
candidates = matcher.match
|
|
79
|
+
return [] if candidates.empty?
|
|
80
|
+
|
|
81
|
+
# Step 2: Select responders (Arbiter) - with policy-based floor control
|
|
82
|
+
selected = arbiter.select(candidates)
|
|
83
|
+
return [] if selected.empty?
|
|
84
|
+
|
|
85
|
+
# Step 3: Schedule execution (Scheduler) - Phase 3
|
|
86
|
+
# For now, immediate execution
|
|
87
|
+
decisions = scheduler.schedule(selected)
|
|
88
|
+
|
|
89
|
+
# Step 4: Enqueue jobs
|
|
90
|
+
enqueue_jobs(decisions)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def policy_resolver
|
|
96
|
+
@policy_resolver ||= PolicyResolver.new(@context)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def matcher
|
|
100
|
+
@matcher ||= Matcher.new(@context)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def arbiter
|
|
104
|
+
@arbiter ||= Arbiter.new(@context, policy_resolver: policy_resolver)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def scheduler
|
|
108
|
+
@scheduler ||= Scheduler.new(@context, policy_resolver: policy_resolver)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def enqueue_jobs(decisions)
|
|
112
|
+
decisions.filter_map do |decision|
|
|
113
|
+
case decision[:timing]
|
|
114
|
+
when :immediate
|
|
115
|
+
AiAgentJob.perform_later(decision[:agent].id, @event_name, @context)
|
|
116
|
+
decision[:agent]
|
|
117
|
+
when :deferred
|
|
118
|
+
Task.create!(
|
|
119
|
+
name: "Response to #{@event_name}",
|
|
120
|
+
status: "queued",
|
|
121
|
+
trigger_event_name: @event_name,
|
|
122
|
+
trigger_event_payload: @context,
|
|
123
|
+
agent: decision[:agent],
|
|
124
|
+
topic_id: @context.dig("topic", "id")
|
|
125
|
+
)
|
|
126
|
+
decision[:agent]
|
|
127
|
+
when :delayed
|
|
128
|
+
AiAgentJob.set(wait: decision[:delay]).perform_later(
|
|
129
|
+
decision[:agent].id, @event_name, @context
|
|
130
|
+
)
|
|
131
|
+
decision[:agent]
|
|
132
|
+
when :rejected
|
|
133
|
+
Rails.logger.info(
|
|
134
|
+
"[Orchestrator] Agent #{decision[:agent].id} rejected: #{decision[:reason]}"
|
|
135
|
+
)
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|