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
@@ -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: #{e.message}"
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: #{e.message}" if block_given?
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
- [ Collavre::Comments::CalendarCommand.new(comment: comment, user: user) ]
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