collavre 0.2.5 → 0.3.0

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 (57) 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 +3 -0
  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/creative.rb +1 -1
  10. data/app/models/collavre/orchestrator_policy.rb +124 -0
  11. data/app/models/collavre/task.rb +3 -0
  12. data/app/models/collavre/user.rb +1 -1
  13. data/app/services/collavre/ai_agent_service.rb +32 -1
  14. data/app/services/collavre/ai_client.rb +4 -3
  15. data/app/services/collavre/comments/command_processor.rb +4 -1
  16. data/app/services/collavre/comments/topic_command.rb +106 -0
  17. data/app/services/collavre/orchestration/agent_context_builder.rb +201 -0
  18. data/app/services/collavre/orchestration/agent_orchestrator.rb +128 -0
  19. data/app/services/collavre/orchestration/arbiter.rb +257 -0
  20. data/app/services/collavre/orchestration/loop_breaker.rb +294 -0
  21. data/app/services/collavre/orchestration/matcher.rb +98 -0
  22. data/app/services/collavre/orchestration/policy_resolver.rb +170 -0
  23. data/app/services/collavre/orchestration/resource_tracker.rb +135 -0
  24. data/app/services/collavre/orchestration/scheduler.rb +145 -0
  25. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +231 -0
  26. data/app/services/collavre/orchestration/stuck_detector.rb +275 -0
  27. data/app/services/collavre/system_events/context_builder.rb +32 -0
  28. data/app/services/collavre/system_events/dispatcher.rb +2 -7
  29. data/app/services/collavre/tools/cron_cancel_service.rb +49 -0
  30. data/app/services/collavre/tools/cron_create_service.rb +73 -0
  31. data/app/services/collavre/tools/cron_list_service.rb +65 -0
  32. data/app/services/collavre/tools/cron_update_service.rb +82 -0
  33. data/app/views/admin/shared/_tabs.html.erb +1 -0
  34. data/app/views/collavre/admin/orchestration/show.html.erb +66 -0
  35. data/app/views/collavre/creatives/index.html.erb +1 -1
  36. data/config/locales/ai_agent.en.yml +21 -0
  37. data/config/locales/ai_agent.ko.yml +21 -0
  38. data/config/locales/comments.en.yml +7 -0
  39. data/config/locales/comments.ko.yml +7 -0
  40. data/config/routes.rb +0 -1
  41. data/db/migrate/20260206005035_create_orchestrator_policies.rb +32 -0
  42. data/db/migrate/20260206094509_add_retry_count_to_tasks.rb +5 -0
  43. data/db/migrate/20260206100000_add_topic_id_to_tasks.rb +6 -0
  44. data/lib/collavre/version.rb +1 -1
  45. metadata +24 -13
  46. data/app/controllers/collavre/github_auth_controller.rb +0 -25
  47. data/app/models/collavre/github_account.rb +0 -10
  48. data/app/models/collavre/github_repository_link.rb +0 -19
  49. data/app/services/collavre/github/client.rb +0 -112
  50. data/app/services/collavre/github/pull_request_analyzer.rb +0 -280
  51. data/app/services/collavre/github/pull_request_processor.rb +0 -181
  52. data/app/services/collavre/github/webhook_provisioner.rb +0 -130
  53. data/app/services/collavre/system_events/router.rb +0 -96
  54. data/app/views/collavre/creatives/_github_integration_modal.html.erb +0 -77
  55. data/db/migrate/20250925000000_create_github_integrations.rb +0 -26
  56. data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +0 -29
  57. data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +0 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ad04ae23058f911aed4ecb6d53a0b650b3e90de1296258cc3a481b5d2984e60
4
- data.tar.gz: a7a19242045151694e551ee8f998ca6bc15213804f4b28a0da1e3d8f3b110946
3
+ metadata.gz: bb2d48043704a517d8970498aa1057e5eaa29133304fe4fb82ebe37ac96b8458
4
+ data.tar.gz: d517609fd42be9255120f2faafe4acaf0fa36a2797c96021c5502acb6206af66
5
5
  SHA512:
6
- metadata.gz: 000ca6cfec2bef14c9199f0a8204ce40c498da48a437b765d9f08c8623a86ec4434a2094d29fa9717488cd5cedf9a44e3016699c0e0a37f8e5eee55890fbfa33
7
- data.tar.gz: 7f2235ffb0bbc1c37943f3be68dd17bdc4f0c1185b85019e7b748485e5915b22d24ff41ba62eae5625233820cdb902733ac186f36b9a0082f8a2c1140292759a
6
+ metadata.gz: ca97eb7ec6d858f67b1d3da6f7db767d15a7ba16a14b3dcb883c051a8bef55b2d679441ff8eb32cd9455528ef977f74430e535553cf9511d74024a23d7c8d0e7
7
+ data.tar.gz: a5cccbe54450cc225d25935ab85720302f61354785dfac02c1170933369c470767786a7c2fc683a93f907ffdc6ce5a34cb2abd1fec8b77aa6fbce9408c68873d
@@ -12,20 +12,23 @@ class CommentsPresenceChannel < ApplicationCable::Channel
12
12
  status: "thinking",
13
13
  agent_id: task.agent_id,
14
14
  agent_name: task.agent.display_name,
15
- task_id: task.id
15
+ task_id: task.id,
16
+ source_creative_id: task_creative_id
16
17
  )
17
18
  end
18
19
  end
19
20
 
20
21
  # Broadcast agent status (thinking/streaming/idle) to presence channel.
21
22
  # This allows the frontend typing indicator to show AI agent activity.
22
- def self.broadcast_agent_status(creative_id, status:, agent_id:, agent_name:, task_id: nil, content: nil)
23
+ # source_creative_id: the actual creative where agent is working (for filtering on frontend)
24
+ def self.broadcast_agent_status(creative_id, status:, agent_id:, agent_name:, task_id: nil, content: nil, source_creative_id: nil)
23
25
  payload = {
24
26
  agent_status: {
25
27
  id: agent_id,
26
28
  name: agent_name,
27
29
  status: status,
28
- task_id: task_id
30
+ task_id: task_id,
31
+ creative_id: source_creative_id || creative_id
29
32
  }
30
33
  }
31
34
  payload[:agent_status][:content] = content if content.present?
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Admin
5
+ class OrchestrationController < ApplicationController
6
+ before_action :require_system_admin!
7
+
8
+ def show
9
+ @policies_yaml = policies_to_yaml
10
+ end
11
+
12
+ def update
13
+ yaml_content = params[:policies_yaml].to_s
14
+
15
+ begin
16
+ parsed = YAML.safe_load(yaml_content, permitted_classes: [ Symbol ])
17
+ validate_policies!(parsed)
18
+ apply_policies!(parsed)
19
+
20
+ redirect_to main_app.admin_orchestration_path, notice: t("admin.orchestration.updated")
21
+ rescue Psych::SyntaxError => e
22
+ flash.now[:alert] = t("admin.orchestration.yaml_syntax_error", message: e.message)
23
+ @policies_yaml = yaml_content
24
+ render :show, status: :unprocessable_entity
25
+ rescue PolicyValidationError => e
26
+ flash.now[:alert] = e.message
27
+ @policies_yaml = yaml_content
28
+ render :show, status: :unprocessable_entity
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ class PolicyValidationError < StandardError; end
35
+
36
+ def policies_to_yaml
37
+ policies = OrchestratorPolicy.enabled.order(:policy_type, :scope_type, :priority)
38
+
39
+ # Group by type for readable YAML structure
40
+ structure = {
41
+ "arbitration" => { "global" => nil, "overrides" => [] },
42
+ "scheduling" => { "global" => nil, "overrides" => [] }
43
+ }
44
+
45
+ policies.each do |policy|
46
+ type = policy.policy_type
47
+ next unless structure.key?(type)
48
+
49
+ if policy.global?
50
+ structure[type]["global"] = policy.config
51
+ else
52
+ structure[type]["overrides"] << {
53
+ "scope_type" => policy.scope_type,
54
+ "scope_id" => policy.scope_id,
55
+ "config" => policy.config,
56
+ "priority" => policy.priority
57
+ }
58
+ end
59
+ end
60
+
61
+ # Remove empty sections
62
+ structure.each do |type, data|
63
+ data.delete("global") if data["global"].nil?
64
+ data.delete("overrides") if data["overrides"].empty?
65
+ end
66
+ structure.delete_if { |_, v| v.empty? }
67
+
68
+ # Add defaults if empty
69
+ if structure.empty?
70
+ structure = default_policies_structure
71
+ end
72
+
73
+ structure.to_yaml
74
+ end
75
+
76
+ def default_policies_structure
77
+ {
78
+ "arbitration" => {
79
+ "global" => {
80
+ "strategy" => "all",
81
+ "max_responders" => nil
82
+ }
83
+ },
84
+ "scheduling" => {
85
+ "global" => {
86
+ "max_concurrent_jobs" => 5,
87
+ "daily_token_limit" => 100_000,
88
+ "rate_limit_per_minute" => 20,
89
+ "backoff_strategy" => "exponential",
90
+ "topic_max_concurrent_jobs" => 1
91
+ }
92
+ }
93
+ }
94
+ end
95
+
96
+ def validate_policies!(parsed)
97
+ raise PolicyValidationError, t("admin.orchestration.invalid_format") unless parsed.is_a?(Hash)
98
+
99
+ parsed.each do |type, data|
100
+ unless %w[arbitration scheduling].include?(type)
101
+ raise PolicyValidationError, t("admin.orchestration.unknown_policy_type", type: type)
102
+ end
103
+
104
+ unless data.is_a?(Hash)
105
+ raise PolicyValidationError, t("admin.orchestration.invalid_policy_structure", type: type)
106
+ end
107
+
108
+ if data["global"].present? && !data["global"].is_a?(Hash)
109
+ raise PolicyValidationError, t("admin.orchestration.invalid_global_config", type: type)
110
+ end
111
+
112
+ if data["overrides"].present?
113
+ unless data["overrides"].is_a?(Array)
114
+ raise PolicyValidationError, t("admin.orchestration.invalid_overrides", type: type)
115
+ end
116
+
117
+ data["overrides"].each_with_index do |override, idx|
118
+ validate_override!(type, override, idx)
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ def validate_override!(type, override, idx)
125
+ unless override.is_a?(Hash)
126
+ raise PolicyValidationError, t("admin.orchestration.invalid_override_format", type: type, index: idx)
127
+ end
128
+
129
+ unless %w[Creative Topic User].include?(override["scope_type"])
130
+ raise PolicyValidationError, t("admin.orchestration.invalid_scope_type",
131
+ type: type, index: idx, scope_type: override["scope_type"])
132
+ end
133
+
134
+ unless override["scope_id"].is_a?(Integer) && override["scope_id"].positive?
135
+ raise PolicyValidationError, t("admin.orchestration.invalid_scope_id", type: type, index: idx)
136
+ end
137
+
138
+ unless override["config"].is_a?(Hash)
139
+ raise PolicyValidationError, t("admin.orchestration.invalid_override_config", type: type, index: idx)
140
+ end
141
+ end
142
+
143
+ def apply_policies!(parsed)
144
+ OrchestratorPolicy.transaction do
145
+ # Clear existing policies
146
+ OrchestratorPolicy.delete_all
147
+
148
+ parsed.each do |type, data|
149
+ # Create global policy
150
+ if data["global"].present?
151
+ OrchestratorPolicy.create!(
152
+ policy_type: type,
153
+ scope_type: nil,
154
+ scope_id: nil,
155
+ config: data["global"],
156
+ priority: 100,
157
+ enabled: true
158
+ )
159
+ end
160
+
161
+ # Create override policies
162
+ data["overrides"]&.each_with_index do |override, idx|
163
+ OrchestratorPolicy.create!(
164
+ policy_type: type,
165
+ scope_type: override["scope_type"],
166
+ scope_id: override["scope_id"],
167
+ config: override["config"],
168
+ priority: override["priority"] || (50 - idx),
169
+ enabled: true
170
+ )
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -173,6 +173,9 @@ module Collavre
173
173
  id: @creative.id,
174
174
  description: @creative.description
175
175
  },
176
+ topic: {
177
+ id: @comment.topic_id
178
+ },
176
179
  chat: {
177
180
  content: @comment.content
178
181
  }
@@ -151,7 +151,11 @@ export default class extends Controller {
151
151
  this.renderTypingIndicator()
152
152
  }
153
153
  if (data.agent_status) {
154
- const { id, name, status } = data.agent_status
154
+ const { id, name, status, creative_id: agentCreativeId } = data.agent_status
155
+ // Only show typing indicator if agent is working on this specific creative
156
+ if (agentCreativeId && agentCreativeId !== this.creativeId) {
157
+ return
158
+ }
155
159
  if (status === 'thinking' || status === 'streaming') {
156
160
  this.typingUsers[id] = name
157
161
  } else {
@@ -8,7 +8,9 @@ module Collavre
8
8
  # Resume existing task
9
9
  task = agent_id_or_task
10
10
  return if task.reload.status == "cancelled"
11
+
11
12
  task.update!(status: "running")
13
+ agent = task.agent
12
14
  else
13
15
  # Create new task
14
16
  agent = User.find(agent_id_or_task)
@@ -17,25 +19,74 @@ module Collavre
17
19
  status: "running",
18
20
  trigger_event_name: event_name,
19
21
  trigger_event_payload: context,
20
- agent: agent
22
+ agent: agent,
23
+ topic_id: context&.dig("topic", "id")
21
24
  )
25
+
26
+ # Record task for loop breaker tracking
27
+ creative_id = context&.dig("creative", "id")
28
+ if creative_id
29
+ Orchestration::LoopBreaker.new(context).record_task(creative_id, agent.id)
30
+ end
22
31
  end
23
32
 
33
+ # Reserve resources before starting work
34
+ tracker = Orchestration::ResourceTracker.for(agent)
35
+ tracker.reserve!(job_id || task.id)
36
+
24
37
  begin
25
- AiAgentService.new(task).call
26
- task.update!(status: "done")
38
+ response_content = AiAgentService.new(task).call
39
+
40
+ # Evaluate self-reflection if enabled
41
+ reflection_result = evaluate_self_reflection(task, response_content)
42
+
43
+ case reflection_result.action
44
+ when :retry
45
+ # Schedule retry with delay - don't release resources yet
46
+ schedule_self_reflection_retry(task, reflection_result, response_content)
47
+ Rails.logger.info(
48
+ "[AiAgentJob] Task #{task.id} scheduled for retry " \
49
+ "(attempt #{task.retry_count + 1}, confidence: #{reflection_result.confidence})"
50
+ )
51
+ nil # Exit without releasing resources or dequeuing
52
+ when :escalate
53
+ # Escalate to admins
54
+ Orchestration::SelfReflectionEvaluator.new(task, response_content: response_content).escalate!(reflection_result)
55
+ tracker.release!(job_id || task.id, tokens_used: 0)
56
+ Rails.logger.info("[AiAgentJob] Task #{task.id} escalated after max retries")
57
+ else # :done
58
+ task.update!(status: "done")
59
+ tracker.release!(job_id || task.id, tokens_used: 0)
60
+ end
27
61
  rescue ApprovalPendingError
28
62
  # Task status already set to pending_approval by AiAgentService
29
- # Don't mark as failed, just let the job complete gracefully
63
+ # Don't release resources yet - task will resume
30
64
  Rails.logger.info("AiAgentJob paused for task #{task.id}: awaiting tool approval")
31
65
  rescue CancelledError
32
66
  # Task status already set to "cancelled" by Comment callback
67
+ tracker.release!(job_id || task.id, tokens_used: 0)
33
68
  Rails.logger.info("AiAgentJob cancelled for task #{task.id}: trigger message deleted")
34
69
  rescue StandardError => e
35
70
  task.update!(status: "failed")
71
+ tracker.release!(job_id || task.id, tokens_used: 0)
36
72
  Rails.logger.error("AiAgentJob failed for task #{task.id}: #{e.message}")
37
73
  raise e
74
+ ensure
75
+ if task&.trigger_event_payload&.key?("topic") && %w[done failed cancelled escalated].include?(task.reload.status)
76
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id)
77
+ end
38
78
  end
39
79
  end
80
+
81
+ private
82
+
83
+ def evaluate_self_reflection(task, response_content)
84
+ Orchestration::SelfReflectionEvaluator.new(task, response_content: response_content).evaluate
85
+ end
86
+
87
+ def schedule_self_reflection_retry(task, result, response_content)
88
+ evaluator = Orchestration::SelfReflectionEvaluator.new(task, response_content: response_content)
89
+ evaluator.schedule_retry!(result)
90
+ end
40
91
  end
41
92
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # CronActionJob is executed by SolidQueue recurring tasks to post
5
+ # a scheduled message into a creative's topic, triggering the
6
+ # agent orchestration pipeline.
7
+ #
8
+ # Created dynamically via the cron_create MCP tool.
9
+ class CronActionJob < ApplicationJob
10
+ queue_as :default
11
+
12
+ def perform(creative_id:, topic_id:, agent_id:, message:)
13
+ creative = Creative.find_by(id: creative_id)
14
+ topic = Topic.find_by(id: topic_id)
15
+ agent = User.find_by(id: agent_id)
16
+
17
+ unless creative && topic && agent
18
+ Rails.logger.warn(
19
+ "[CronActionJob] Skipping: creative=#{creative_id} topic=#{topic_id} agent=#{agent_id} - record not found"
20
+ )
21
+ return
22
+ end
23
+
24
+ comment = creative.comments.create!(
25
+ content: message,
26
+ user: agent,
27
+ topic_id: topic.id,
28
+ private: false
29
+ )
30
+
31
+ # Dispatch system event to trigger agent orchestration pipeline
32
+ SystemEvents::Dispatcher.dispatch("comment_created", {
33
+ comment: {
34
+ id: comment.id,
35
+ content: comment.content,
36
+ user_id: comment.user_id
37
+ },
38
+ creative: {
39
+ id: creative.id,
40
+ description: creative.description
41
+ },
42
+ topic: {
43
+ id: topic.id
44
+ },
45
+ chat: {
46
+ content: comment.content
47
+ }
48
+ })
49
+
50
+ Rails.logger.info("[CronActionJob] Posted cron message to creative #{creative_id}, topic #{topic_id}")
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # StuckDetectorJob runs periodically to detect stuck tasks and creatives.
5
+ #
6
+ # This job should be scheduled to run every 5-10 minutes via cron or similar.
7
+ #
8
+ # Usage:
9
+ # StuckDetectorJob.perform_later
10
+ #
11
+ # To schedule with SolidQueue:
12
+ # # config/recurring.yml
13
+ # stuck_detector:
14
+ # class: Collavre::StuckDetectorJob
15
+ # schedule: every 10 minutes
16
+ #
17
+ class StuckDetectorJob < ApplicationJob
18
+ queue_as :default
19
+
20
+ def perform
21
+ detector = Orchestration::StuckDetector.new
22
+ result = detector.detect_and_escalate
23
+
24
+ if result.escalated_count > 0
25
+ Rails.logger.info(
26
+ "[StuckDetectorJob] Detected #{result.stuck_items.count} stuck items, " \
27
+ "escalated #{result.escalated_count}"
28
+ )
29
+ end
30
+
31
+ result
32
+ end
33
+ end
34
+ end
@@ -59,7 +59,7 @@ module Collavre
59
59
  has_many :tags, class_name: "Collavre::Tag", dependent: :destroy
60
60
  has_many :creative_expanded_states, class_name: "Collavre::CreativeExpandedState", dependent: :delete_all
61
61
  has_many :invitations, class_name: "Collavre::Invitation", dependent: :delete_all
62
- has_many :github_repository_links, dependent: :destroy
62
+ # github_repository_links association added by CollavreGithub engine
63
63
  has_many :topics, class_name: "Collavre::Topic", dependent: :destroy
64
64
  has_many :mcp_tools, dependent: :destroy
65
65
  has_many :activity_logs, class_name: "Collavre::ActivityLog", dependent: :destroy
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # OrchestratorPolicy stores configuration for agent orchestration behavior.
5
+ #
6
+ # Table: orchestrator_policies
7
+ #
8
+ # Scope types:
9
+ # - nil (global): Default policy applied everywhere
10
+ # - "Creative": Policy for a specific creative/workspace
11
+ # - "Topic": Policy for a specific topic/conversation
12
+ # - "User": Policy for a specific agent
13
+ #
14
+ # Policy types:
15
+ # - matching: How agents are matched to messages
16
+ # - arbitration: How to select responders from candidates (floor control)
17
+ # - scheduling: When/how to execute agent jobs (resource management)
18
+ #
19
+ # Example configs:
20
+ #
21
+ # Arbitration policy (primary_first):
22
+ # {
23
+ # "strategy": "primary_first",
24
+ # "max_responders": 1,
25
+ # "primary_agent_id": 123
26
+ # }
27
+ #
28
+ # Arbitration policy (round_robin):
29
+ # {
30
+ # "strategy": "round_robin",
31
+ # "max_responders": 1
32
+ # }
33
+ #
34
+ class OrchestratorPolicy < ApplicationRecord
35
+ self.table_name = "orchestrator_policies"
36
+
37
+ # Scope types
38
+ SCOPE_TYPES = %w[Creative Topic User].freeze
39
+
40
+ # Policy types
41
+ POLICY_TYPES = %w[matching arbitration scheduling stuck_detection].freeze
42
+
43
+ # Arbitration strategies
44
+ ARBITRATION_STRATEGIES = %w[all primary_first round_robin bid].freeze
45
+
46
+ # Validations
47
+ validates :policy_type, presence: true, inclusion: { in: POLICY_TYPES }
48
+ validates :scope_type, inclusion: { in: SCOPE_TYPES }, allow_nil: true
49
+ validates :priority, presence: true, numericality: { only_integer: true }
50
+
51
+ # Polymorphic scope
52
+ belongs_to :scope, polymorphic: true, optional: true
53
+
54
+ # Scopes
55
+ scope :enabled, -> { where(enabled: true) }
56
+ scope :by_priority, -> { order(priority: :asc) }
57
+ scope :global, -> { where(scope_type: nil, scope_id: nil) }
58
+ scope :for_type, ->(type) { where(policy_type: type) }
59
+
60
+ # Class methods
61
+ class << self
62
+ # Find policies applicable to a given context
63
+ # Returns policies in priority order (global < creative < topic < agent)
64
+ def for_context(context, policy_type:)
65
+ policies = enabled.for_type(policy_type).by_priority
66
+
67
+ applicable = []
68
+
69
+ # Global policies
70
+ applicable.concat(policies.global.to_a)
71
+
72
+ # Creative-level policies
73
+ if context["creative"].present?
74
+ creative_id = context["creative"]["id"]
75
+ applicable.concat(
76
+ policies.where(scope_type: "Creative", scope_id: creative_id).to_a
77
+ )
78
+ end
79
+
80
+ # Topic-level policies
81
+ if context["topic"].present?
82
+ topic_id = context["topic"]["id"]
83
+ applicable.concat(
84
+ policies.where(scope_type: "Topic", scope_id: topic_id).to_a
85
+ )
86
+ end
87
+
88
+ # Agent-level policies are handled separately per-agent
89
+
90
+ # Note: We don't sort by priority globally here because scope hierarchy
91
+ # (global < creative < topic) takes precedence. Each scope level is
92
+ # already sorted by priority from the query.
93
+ applicable
94
+ end
95
+
96
+ # Find policies for a specific agent
97
+ def for_agent(agent_id, policy_type:)
98
+ enabled
99
+ .for_type(policy_type)
100
+ .where(scope_type: "User", scope_id: agent_id)
101
+ .by_priority
102
+ end
103
+ end
104
+
105
+ # Instance methods
106
+
107
+ # Get a config value with default
108
+ def config_value(key, default: nil)
109
+ config[key.to_s] || default
110
+ end
111
+
112
+ # Check if this is a global policy
113
+ def global?
114
+ scope_type.nil? && scope_id.nil?
115
+ end
116
+
117
+ # Get the arbitration strategy
118
+ def arbitration_strategy
119
+ return nil unless policy_type == "arbitration"
120
+
121
+ config_value("strategy", default: "all")
122
+ end
123
+ end
124
+ end
@@ -6,5 +6,8 @@ module Collavre
6
6
  has_many :task_actions, class_name: "Collavre::TaskAction", dependent: :destroy
7
7
 
8
8
  validates :name, presence: true
9
+
10
+ scope :running_for_topic, ->(topic_id) { where(topic_id: topic_id, status: "running") }
11
+ scope :queued_for_topic, ->(topic_id) { where(topic_id: topic_id, status: "queued").order(:created_at) }
9
12
  end
10
13
  end
@@ -16,7 +16,7 @@ module Collavre
16
16
  has_many :contacts, class_name: "Collavre::Contact", dependent: :destroy
17
17
  has_many :contact_users, through: :contacts
18
18
  has_many :contact_memberships, class_name: "Collavre::Contact", foreign_key: :contact_user_id, dependent: :destroy, inverse_of: :contact_user
19
- has_one :github_account, class_name: "Collavre::GithubAccount", dependent: :destroy
19
+ # github_account association is added dynamically by collavre_github engine
20
20
  has_many :tasks, class_name: "Collavre::Task", foreign_key: :agent_id, dependent: :destroy
21
21
 
22
22
  # Associations that reference creatives - must be destroyed BEFORE creatives
@@ -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