collavre 0.3.2 → 0.5.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +73 -71
  3. data/app/assets/stylesheets/collavre/activity_logs.css +18 -45
  4. data/app/assets/stylesheets/collavre/comments_popup.css +197 -35
  5. data/app/assets/stylesheets/collavre/creatives.css +101 -51
  6. data/app/assets/stylesheets/collavre/dark_mode.css +221 -88
  7. data/app/assets/stylesheets/collavre/design_tokens.css +334 -0
  8. data/app/assets/stylesheets/collavre/mention_menu.css +13 -9
  9. data/app/assets/stylesheets/collavre/popup.css +57 -27
  10. data/app/assets/stylesheets/collavre/slide_view.css +6 -6
  11. data/app/assets/stylesheets/collavre/user_menu.css +4 -5
  12. data/app/components/collavre/plans_timeline_component.html.erb +2 -2
  13. data/app/controllers/collavre/admin/orchestration_controller.rb +9 -2
  14. data/app/controllers/collavre/admin/settings_controller.rb +199 -0
  15. data/app/controllers/collavre/comments/reactions_controller.rb +1 -9
  16. data/app/controllers/collavre/comments_controller.rb +39 -162
  17. data/app/controllers/collavre/creatives_controller.rb +18 -58
  18. data/app/controllers/collavre/users_controller.rb +31 -3
  19. data/app/helpers/collavre/application_helper.rb +97 -0
  20. data/app/helpers/collavre/creatives_helper.rb +10 -202
  21. data/app/javascript/collavre.js +0 -1
  22. data/app/javascript/components/creative_tree_row.js +3 -2
  23. data/app/javascript/controllers/comment_controller.js +309 -4
  24. data/app/javascript/controllers/comments/form_controller.js +52 -0
  25. data/app/javascript/controllers/comments/presence_controller.js +13 -0
  26. data/app/javascript/controllers/creatives/tree_controller.js +2 -1
  27. data/app/javascript/controllers/link_creative_controller.js +29 -3
  28. data/app/javascript/lib/__tests__/html_code_block_wrapper.test.js +201 -0
  29. data/app/javascript/lib/html_code_block_wrapper.js +168 -0
  30. data/app/javascript/lib/utils/markdown.js +2 -1
  31. data/app/javascript/modules/creative_row_editor.js +5 -1
  32. data/app/javascript/utils/emoji_parser.js +21 -0
  33. data/app/jobs/collavre/ai_agent_job.rb +6 -2
  34. data/app/jobs/collavre/cron_action_job.rb +18 -6
  35. data/app/jobs/collavre/cron_scheduler_job.rb +112 -0
  36. data/app/models/collavre/comment/approvable.rb +50 -0
  37. data/app/models/collavre/comment/broadcastable.rb +119 -0
  38. data/app/models/collavre/comment/notifiable.rb +111 -0
  39. data/app/models/collavre/comment.rb +13 -258
  40. data/app/models/collavre/comment_reaction.rb +15 -0
  41. data/app/models/collavre/creative/describable.rb +86 -0
  42. data/app/models/collavre/creative/linkable.rb +77 -0
  43. data/app/models/collavre/creative/permissible.rb +103 -0
  44. data/app/models/collavre/creative.rb +3 -289
  45. data/app/models/collavre/orchestrator_policy.rb +1 -1
  46. data/app/models/collavre/system_setting.rb +27 -1
  47. data/app/models/collavre/user.rb +42 -0
  48. data/app/models/collavre/user_theme.rb +10 -0
  49. data/app/services/collavre/ai_agent/approval_handler.rb +110 -0
  50. data/app/services/collavre/ai_agent/message_builder.rb +129 -0
  51. data/app/services/collavre/ai_agent/review_handler.rb +70 -0
  52. data/app/services/collavre/ai_agent_service.rb +93 -150
  53. data/app/services/collavre/ai_client.rb +23 -4
  54. data/app/services/collavre/auto_theme_generator.rb +168 -50
  55. data/app/services/collavre/command_menu_service.rb +70 -0
  56. data/app/services/collavre/comment_move_service.rb +94 -0
  57. data/app/services/collavre/comments/action_executor.rb +10 -0
  58. data/app/services/collavre/comments/mcp_command.rb +1 -2
  59. data/app/services/collavre/creatives/create_service.rb +86 -0
  60. data/app/services/collavre/creatives/destroy_service.rb +41 -0
  61. data/app/services/collavre/creatives/index_query.rb +3 -0
  62. data/app/services/collavre/markdown_converter.rb +240 -0
  63. data/app/services/collavre/mention_parser.rb +63 -0
  64. data/app/services/collavre/orchestration/agent_context_builder.rb +24 -8
  65. data/app/services/collavre/orchestration/agent_orchestrator.rb +59 -10
  66. data/app/services/collavre/orchestration/loop_breaker.rb +12 -7
  67. data/app/services/collavre/orchestration/policy_resolver.rb +16 -2
  68. data/app/services/collavre/orchestration/scheduler.rb +4 -3
  69. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  70. data/app/services/collavre/system_events/context_builder.rb +1 -6
  71. data/app/services/collavre/tools/creative_batch_service.rb +107 -0
  72. data/app/services/collavre/tools/creative_update_service.rb +17 -12
  73. data/app/services/collavre/tools/cron_create_service.rb +17 -5
  74. data/app/views/admin/shared/_tabs.html.erb +2 -1
  75. data/app/views/collavre/admin/orchestration/show.html.erb +11 -0
  76. data/app/views/collavre/admin/settings/_system_tab.html.erb +138 -0
  77. data/app/views/collavre/admin/settings/_uiux_tab.html.erb +44 -0
  78. data/app/views/collavre/admin/settings/index.html.erb +11 -0
  79. data/app/views/collavre/admin/settings/uiux.html.erb +11 -0
  80. data/app/views/collavre/comments/_comment.html.erb +15 -5
  81. data/app/views/collavre/comments/_comments_popup.html.erb +9 -2
  82. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +0 -3
  83. data/app/views/collavre/creatives/_share_button.html.erb +0 -52
  84. data/app/views/collavre/creatives/_share_modal.html.erb +52 -0
  85. data/app/views/collavre/creatives/index.html.erb +5 -8
  86. data/app/views/collavre/shared/navigation/_panels.html.erb +2 -2
  87. data/app/views/collavre/user_themes/index.html.erb +7 -9
  88. data/app/views/collavre/users/_contact_management.html.erb +2 -1
  89. data/app/views/collavre/users/edit_ai.html.erb +7 -0
  90. data/app/views/collavre/users/index.html.erb +16 -1
  91. data/app/views/collavre/users/new_ai.html.erb +18 -8
  92. data/app/views/collavre/users/passkeys.html.erb +1 -1
  93. data/app/views/collavre/users/show.html.erb +1 -1
  94. data/app/views/layouts/collavre/slide.html.erb +8 -1
  95. data/config/locales/admin.en.yml +88 -0
  96. data/config/locales/admin.ko.yml +88 -0
  97. data/config/locales/ai_agent.en.yml +5 -1
  98. data/config/locales/ai_agent.ko.yml +5 -1
  99. data/config/locales/comments.en.yml +5 -1
  100. data/config/locales/comments.ko.yml +5 -1
  101. data/config/locales/orchestration.en.yml +8 -0
  102. data/config/locales/orchestration.ko.yml +8 -0
  103. data/config/locales/users.en.yml +12 -0
  104. data/config/locales/users.ko.yml +12 -0
  105. data/config/routes.rb +7 -1
  106. data/db/migrate/20260212011655_add_quoted_comment_to_comments.rb +7 -0
  107. data/db/migrate/20260213044247_add_agent_conf_to_users.rb +5 -0
  108. data/lib/collavre/engine.rb +25 -0
  109. data/lib/collavre/version.rb +1 -1
  110. metadata +32 -1
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module AiAgent
5
+ # Builds the message array for AI agent conversations.
6
+ #
7
+ # Extracts creative context, chat history, and the trigger comment
8
+ # into the format expected by AiClient.
9
+ class MessageBuilder
10
+ def initialize(agent:, context:, original_comment: nil)
11
+ @agent = agent
12
+ @context = context
13
+ @original_comment = original_comment
14
+ end
15
+
16
+ def build
17
+ messages = []
18
+
19
+ append_creative_context(messages)
20
+ append_chat_history(messages)
21
+ append_trigger_message(messages)
22
+
23
+ messages
24
+ end
25
+
26
+ private
27
+
28
+ def append_creative_context(messages)
29
+ creative_id = @context.dig("creative", "id")
30
+ return unless creative_id
31
+
32
+ creative = Creative.find_by(id: creative_id)
33
+ return unless creative
34
+
35
+ children_level = @agent.creative_children_level
36
+ max_depth = 1 + children_level
37
+ markdown = ApplicationController.helpers.render_creative_tree_markdown(
38
+ [ creative ], 1, true, max_depth: max_depth
39
+ )
40
+
41
+ topic = current_topic
42
+ topic_info = topic ? "\nTopic: #{topic.name} (id: #{topic.id})" : ""
43
+
44
+ messages << { role: "user", parts: [ { text: "Creative (id: #{creative.id}):#{topic_info}\n#{markdown}" } ] }
45
+ end
46
+
47
+ def append_chat_history(messages)
48
+ creative_id = @context.dig("creative", "id")
49
+ return unless creative_id
50
+
51
+ topic_id = trigger_comment&.topic_id
52
+
53
+ history_limit = @agent.chat_history_limit
54
+ history_size_limit = @agent.chat_history_size_limit
55
+ history_chars = 0
56
+
57
+ Comment.where(creative_id: creative_id, private: false)
58
+ .where(topic_id: topic_id)
59
+ .where.not(user_id: nil)
60
+ .includes(:user)
61
+ .order(created_at: :desc)
62
+ .limit(history_limit)
63
+ .reverse
64
+ .each do |c|
65
+ next if c.id == trigger_comment&.id
66
+
67
+ role = (c.user_id == @agent.id) ? "model" : "user"
68
+ content = c.content.to_s
69
+
70
+ if role == "user"
71
+ content = MentionParser.strip_self_mention(content, @agent.name)
72
+ speaker = c.user&.name || "unknown"
73
+ content = "[#{speaker}]: #{content}"
74
+ end
75
+
76
+ history_chars += content.length
77
+ break if history_chars > history_size_limit
78
+
79
+ messages << { role: role, parts: [ { text: content } ] }
80
+ end
81
+ end
82
+
83
+ def append_trigger_message(messages)
84
+ payload_text = @context.dig("comment", "content") || @context.to_json
85
+
86
+ if review_eligible?
87
+ quoted_body = @original_comment.quoted_comment&.content
88
+ review_context = I18n.t("collavre.ai_agent.review.context")
89
+ review_parts = [ review_context ]
90
+ review_parts << "---\nOriginal message:\n#{quoted_body}\n---" if quoted_body.present?
91
+ payload_text = "#{review_parts.join("\n\n")}\n\n#{payload_text}"
92
+ end
93
+
94
+ sender_name = @context.dig("sender", "name")
95
+ if sender_name
96
+ payload_text = MentionParser.strip_self_mention(payload_text, @agent.name)
97
+ payload_text = "[#{sender_name}]: #{payload_text}"
98
+ end
99
+
100
+ trigger_parts = [ { text: payload_text } ]
101
+
102
+ if @original_comment&.images&.attached?
103
+ @original_comment.images.each do |image|
104
+ trigger_parts << { image: image.blob }
105
+ end
106
+ end
107
+
108
+ messages << { role: "user", parts: trigger_parts }
109
+ end
110
+
111
+ def trigger_comment
112
+ @trigger_comment ||= begin
113
+ id = @context.dig("comment", "id")
114
+ Comment.find_by(id: id) if id
115
+ end
116
+ end
117
+
118
+ def current_topic
119
+ return unless trigger_comment&.topic_id
120
+
121
+ Topic.find_by(id: trigger_comment.topic_id)
122
+ end
123
+
124
+ def review_eligible?
125
+ ReviewHandler.eligible?(@original_comment, @agent)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module AiAgent
5
+ # Handles AI agent review workflows: eligibility checks,
6
+ # in-place content updates, and completion reactions.
7
+ class ReviewHandler
8
+ def initialize(original_comment, agent)
9
+ @original_comment = original_comment
10
+ @agent = agent
11
+ end
12
+
13
+ # Class-level convenience for eligibility checks.
14
+ def self.eligible?(original_comment, agent)
15
+ new(original_comment, agent).eligible?
16
+ end
17
+
18
+ def eligible?
19
+ return false unless @original_comment&.review_message?
20
+
21
+ quoted_comment = @original_comment.quoted_comment
22
+ return false unless quoted_comment
23
+ return false unless quoted_comment.user_id == @agent.id
24
+ return false unless quoted_comment.creative_id == @original_comment.creative_id
25
+ return false if quoted_comment.private?
26
+ return false unless quoted_comment.topic_id == @original_comment.topic_id
27
+
28
+ true
29
+ end
30
+
31
+ # Update the quoted comment with the AI response content.
32
+ # Returns true if the review was handled, false otherwise.
33
+ def handle(response_content, task:)
34
+ return false unless eligible?
35
+
36
+ quoted_comment = @original_comment.quoted_comment
37
+ quoted_comment.update!(content: response_content)
38
+
39
+ task.task_actions.create!(
40
+ action_type: "review_updated",
41
+ payload: {
42
+ quoted_comment_id: quoted_comment.id,
43
+ original_comment_id: @original_comment.id,
44
+ content: response_content
45
+ },
46
+ status: "done"
47
+ )
48
+
49
+ true
50
+ end
51
+
52
+ # Add a completion reaction emoji to the review comment.
53
+ def add_completion_reaction
54
+ reaction = begin
55
+ CommentReaction.find_or_create_by!(
56
+ comment: @original_comment,
57
+ user: @agent,
58
+ emoji: "✅"
59
+ )
60
+ rescue ActiveRecord::RecordNotUnique
61
+ CommentReaction.find_by(comment: @original_comment, user: @agent, emoji: "✅")
62
+ end
63
+
64
+ CommentReaction.broadcast_reaction_update(@original_comment) if reaction
65
+ rescue StandardError => e
66
+ Rails.logger.warn("[AiAgent::ReviewHandler] Failed to add review reaction: #{e.message}")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -4,6 +4,8 @@ module Collavre
4
4
  STREAM_THROTTLE_INTERVAL = 0.1
5
5
  # Minimum interval (in seconds) between cancellation checks to avoid excessive DB queries
6
6
  CANCEL_CHECK_INTERVAL = 1.0
7
+ # Interval (in seconds) between agent_status heartbeats during streaming
8
+ AGENT_STATUS_HEARTBEAT_INTERVAL = 3.0
7
9
 
8
10
  def initialize(task)
9
11
  @task = task
@@ -13,26 +15,25 @@ module Collavre
13
15
 
14
16
  def call
15
17
  Current.set(user: @agent) do
16
- # Log start action
17
18
  log_action("start", { message: "Starting agent execution" })
18
19
 
19
- # Prepare messages for AI
20
- messages = build_messages
20
+ target_comment_id = @context.dig("comment", "id")
21
+ @original_comment = target_comment_id ? Comment.find_by(id: target_comment_id) : nil
22
+
23
+ messages = AiAgent::MessageBuilder.new(
24
+ agent: @agent, context: @context, original_comment: @original_comment
25
+ ).build
21
26
 
22
- # Log prompt generation
23
27
  log_action("prompt_generated", { messages: messages })
24
28
 
25
- # Call AI Client
26
29
  @response_content = ""
27
30
 
28
- # Enrich context for rendering
29
31
  rendering_context = @context.dup
30
32
  if @context.dig("creative", "id")
31
33
  creative = Creative.find_by(id: @context["creative"]["id"])
32
34
  rendering_context["creative"] = creative.as_json if creative
33
35
  end
34
36
 
35
- # Add agent collaboration context
36
37
  agent_context = build_agent_context(creative)
37
38
  rendering_context.merge!(agent_context)
38
39
 
@@ -41,18 +42,14 @@ module Collavre
41
42
  context: rendering_context
42
43
  ).render
43
44
 
44
- # Append collaboration guide to system prompt
45
45
  collaboration_prompt = build_collaboration_prompt(creative)
46
46
  rendered_system_prompt = "#{rendered_system_prompt}\n\n#{collaboration_prompt}" if collaboration_prompt.present?
47
47
 
48
- # Create a placeholder comment to stream into
49
- target_comment_id = @context.dig("comment", "id")
50
- @original_comment = target_comment_id ? Comment.find_by(id: target_comment_id) : nil
51
48
  @reply_comment = nil
52
49
 
53
50
  if @original_comment
54
51
  @reply_comment = @original_comment.creative.comments.create!(
55
- content: Comment::STREAMING_PLACEHOLDER_CONTENT, # Placeholder — replaced during streaming
52
+ content: Comment::STREAMING_PLACEHOLDER_CONTENT,
56
53
  user: @agent,
57
54
  topic_id: @original_comment.topic_id
58
55
  )
@@ -60,7 +57,6 @@ module Collavre
60
57
 
61
58
  @creative = @context.dig("creative", "id") ? Creative.find_by(id: @context["creative"]["id"]) : nil
62
59
 
63
- # Broadcast "thinking" status via presence channel
64
60
  broadcast_agent_status("thinking")
65
61
 
66
62
  client = AiClient.new(
@@ -77,52 +73,45 @@ module Collavre
77
73
  )
78
74
 
79
75
  last_broadcast_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
76
+ last_heartbeat_at = last_broadcast_at
80
77
  @last_cancel_check_at = last_broadcast_at
81
78
 
82
79
  client.chat(messages, tools: @agent.tools || []) do |delta|
83
80
  check_cancelled!
84
81
  @response_content += delta
85
82
 
86
- # Stream updates to placeholder comment (throttled)
87
83
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
84
  if @reply_comment && (now - last_broadcast_at) >= STREAM_THROTTLE_INTERVAL
89
85
  @reply_comment.update_column(:content, @response_content)
90
86
  @reply_comment.broadcast_update_to(
91
87
  [ @reply_comment.creative, :comments ],
92
- partial: "collavre/comments/comment"
88
+ partial: "collavre/comments/comment",
89
+ locals: { comment: @reply_comment, streaming: true }
93
90
  )
94
91
  last_broadcast_at = now
95
92
  end
93
+
94
+ # Periodic agent_status heartbeat so clients know we're still streaming
95
+ if (now - last_heartbeat_at) >= AGENT_STATUS_HEARTBEAT_INTERVAL
96
+ broadcast_agent_status("streaming")
97
+ last_heartbeat_at = now
98
+ end
96
99
  end
97
100
 
98
- # Log completion
99
101
  log_action("completion", { response: @response_content })
100
102
 
101
- # Final save to ensure everything is consistent and trigger final callbacks
102
- if @reply_comment
103
- if @response_content.present?
104
- # Force dirty tracking since update_column bypassed it during streaming
105
- @reply_comment.content_will_change!
106
- @reply_comment.update!(content: @response_content)
107
- log_action("reply_created", { comment_id: @reply_comment.id, content: @response_content })
103
+ finalize_response
108
104
 
109
- # Re-associate activity logs from the trigger comment to the reply comment
110
- reassociate_activity_logs(@original_comment, @reply_comment)
111
- else
112
- @reply_comment.destroy!
113
- end
114
- elsif target_comment_id && @response_content.present?
115
- reply_to_comment(target_comment_id, @response_content)
116
- end
117
-
118
- # Broadcast "idle" status
119
105
  broadcast_agent_status("idle")
120
106
 
121
107
  @response_content
122
108
  end
123
109
  rescue ApprovalPendingError => e
124
- handle_approval_pending(e)
125
- raise # Re-raise to signal the job to handle status
110
+ AiAgent::ApprovalHandler.new(
111
+ task: @task, agent: @agent, context: @context,
112
+ creative: @creative, reply_comment: @reply_comment
113
+ ).handle(e)
114
+ raise
126
115
  rescue CancelledError
127
116
  handle_cancelled
128
117
  raise
@@ -139,11 +128,15 @@ module Collavre
139
128
  end
140
129
 
141
130
  def handle_cancelled
142
- # Save accumulated content to the placeholder comment before stopping
143
131
  if @reply_comment
144
132
  if @response_content.present?
145
133
  @reply_comment.content_will_change!
146
134
  @reply_comment.update!(content: @response_content)
135
+ @reply_comment.broadcast_update_to(
136
+ [ @reply_comment.creative, :comments ],
137
+ partial: "collavre/comments/comment",
138
+ locals: { comment: @reply_comment, streaming: false }
139
+ )
147
140
  log_action("reply_created", { comment_id: @reply_comment.id, content: @response_content, partial: true })
148
141
  reassociate_activity_logs(@original_comment, @reply_comment)
149
142
  else
@@ -155,6 +148,35 @@ module Collavre
155
148
  log_action("cancelled", { message: "Task cancelled by user" })
156
149
  end
157
150
 
151
+ def finalize_response
152
+ review_handler = AiAgent::ReviewHandler.new(@original_comment, @agent)
153
+
154
+ if @reply_comment
155
+ if @response_content.present?
156
+ if @original_comment&.review_message? && review_handler.handle(@response_content, task: @task)
157
+ review_handler.add_completion_reaction
158
+ reassociate_activity_logs(@reply_comment, @original_comment.quoted_comment)
159
+ @reply_comment.destroy!
160
+ else
161
+ @reply_comment.content_will_change!
162
+ @reply_comment.update!(content: @response_content)
163
+ @reply_comment.broadcast_update_to(
164
+ [ @reply_comment.creative, :comments ],
165
+ partial: "collavre/comments/comment",
166
+ locals: { comment: @reply_comment, streaming: false }
167
+ )
168
+ log_action("reply_created", { comment_id: @reply_comment.id, content: @response_content })
169
+ reassociate_activity_logs(@original_comment, @reply_comment)
170
+ dispatch_a2a_if_needed
171
+ end
172
+ else
173
+ @reply_comment.destroy!
174
+ end
175
+ elsif @context.dig("comment", "id") && @response_content.present?
176
+ reply_to_comment(@context["comment"]["id"], @response_content)
177
+ end
178
+ end
179
+
158
180
  def broadcast_agent_status(status, content: nil)
159
181
  return unless @creative
160
182
 
@@ -178,68 +200,46 @@ module Collavre
178
200
  )
179
201
  end
180
202
 
181
- def build_messages
182
- messages = []
183
-
184
- if @context["creative"]
185
- creative_id = @context.dig("creative", "id")
186
- if creative_id
187
- creative = Creative.find_by(id: creative_id)
188
- if creative
189
- markdown = ApplicationController.helpers.render_creative_tree_markdown([ creative ], 1, true)
190
- messages << { role: "user", parts: [ { text: "Creative:\n#{markdown}" } ] }
191
- end
192
- end
193
- end
194
-
195
- if @context.dig("creative", "id")
196
- creative_id = @context["creative"]["id"]
197
-
198
- trigger_comment_id = @context.dig("comment", "id")
199
- trigger_comment = Comment.find_by(id: trigger_comment_id)
200
- topic_id = trigger_comment&.topic_id
201
-
202
- Comment.where(creative_id: creative_id, private: false)
203
- .where(topic_id: topic_id)
204
- .order(created_at: :desc)
205
- .limit(50)
206
- .reverse
207
- .each do |c|
208
- next if c.id == @context.dig("comment", "id")
209
-
210
- role = (c.user_id == @agent.id) ? "model" : "user"
211
- content = c.content
212
-
213
- if role == "user"
214
- if content.match?(/\A@#{Regexp.escape(@agent.name)}:/i)
215
- content = content.sub(/\A@#{Regexp.escape(@agent.name)}:\s*/i, "")
216
- elsif content.match?(/\A@#{Regexp.escape(@agent.name)}\s+/i)
217
- content = content.sub(/\A@#{Regexp.escape(@agent.name)}\s+/i, "")
218
- end
219
- end
220
-
221
- messages << { role: role, parts: [ { text: content } ] }
222
- end
223
- end
224
-
225
- payload_text = @context.dig("comment", "content") || @context.to_json
226
- messages << { role: "user", parts: [ { text: payload_text } ] }
227
-
228
- messages
229
- end
230
-
231
203
  def reply_to_comment(comment_id, content)
232
204
  original_comment = Comment.find_by(id: comment_id)
233
205
  return unless original_comment
234
206
 
235
- reply = original_comment.creative.comments.create!(
207
+ @reply_comment = original_comment.creative.comments.create!(
236
208
  content: content,
237
209
  user: @agent,
238
210
  topic_id: original_comment.topic_id
239
211
  )
240
212
 
241
- log_action("reply_created", { comment_id: reply.id, content: content })
242
- reassociate_activity_logs(original_comment, reply)
213
+ log_action("reply_created", { comment_id: @reply_comment.id, content: content })
214
+ reassociate_activity_logs(original_comment, @reply_comment)
215
+ dispatch_a2a_if_needed
216
+ end
217
+
218
+ def dispatch_a2a_if_needed
219
+ return unless @reply_comment&.content.present?
220
+
221
+ # Find all mentioned AI agents anywhere in the response (not just at the start)
222
+ mentioned_agents = MentionParser.resolve_all_users(@reply_comment.content).select(&:ai_user?)
223
+ return if mentioned_agents.empty?
224
+
225
+ creative = @reply_comment.creative
226
+
227
+ mentioned_agents.each do |mentioned_user|
228
+ if creative
229
+ context = { "creative" => { "id" => creative.id }, "topic" => { "id" => @reply_comment.topic_id } }
230
+ Orchestration::LoopBreaker.new(context).record_interaction(@agent.id, mentioned_user.id, creative.id)
231
+ end
232
+ end
233
+
234
+ # Dispatch event — downstream Matcher will route to the correct agent(s)
235
+ SystemEvents::Dispatcher.dispatch("comment_created", {
236
+ comment: { id: @reply_comment.id, content: @reply_comment.content, user_id: @reply_comment.user_id },
237
+ creative: { id: creative&.id, description: creative&.description },
238
+ topic: { id: @reply_comment.topic_id },
239
+ chat: { content: @reply_comment.content }
240
+ })
241
+ rescue StandardError => e
242
+ Rails.logger.error("[AiAgentService] A2A dispatch failed: #{e.message}")
243
243
  end
244
244
 
245
245
  def reassociate_activity_logs(from_comment, to_comment)
@@ -263,70 +263,13 @@ module Collavre
263
263
  Orchestration::AgentContextBuilder.new(
264
264
  agent: @agent,
265
265
  creative: creative,
266
- sender: @context["sender"]
266
+ sender: @context["sender"],
267
+ policy_resolver: build_policy_resolver
267
268
  ).to_collaboration_prompt
268
269
  end
269
270
 
270
- def handle_approval_pending(error)
271
- # Clean up placeholder comment if exists
272
- @reply_comment&.destroy! if @reply_comment&.content == Comment::STREAMING_PLACEHOLDER_CONTENT
273
-
274
- broadcast_agent_status("idle")
275
-
276
- @task.update!(
277
- status: "pending_approval",
278
- pending_tool_call: {
279
- tool_name: error.tool_name,
280
- tool_call_id: error.tool_call_id,
281
- arguments: error.tool_arguments,
282
- requested_at: Time.current.iso8601
283
- }
284
- )
285
-
286
- log_action("pending_approval", error.to_h)
287
- create_approval_comment(error)
288
- end
289
-
290
- def create_approval_comment(error)
291
- return unless @creative
292
-
293
- approver = @creative.user || User.find_by(id: @context.dig("comment", "user_id"))
294
- return unless approver
295
-
296
- action_payload = {
297
- action: "execute_tool",
298
- tool_name: error.tool_name,
299
- arguments: error.tool_arguments,
300
- resume: {
301
- task_id: @task.id,
302
- tool_call_id: error.tool_call_id
303
- }
304
- }
305
-
306
- args_display = if error.tool_arguments.present?
307
- JSON.pretty_generate(error.tool_arguments)
308
- else
309
- I18n.t("collavre.ai_agent.approval.no_arguments")
310
- end
311
-
312
- content = I18n.t(
313
- "collavre.ai_agent.approval.message",
314
- tool_name: error.tool_name,
315
- arguments: args_display
316
- )
317
-
318
- original_comment = Comment.find_by(id: @context.dig("comment", "id"))
319
- topic_id = original_comment&.topic_id
320
-
321
- Comment.create!(
322
- creative: @creative,
323
- content: content,
324
- user: @agent,
325
- approver: approver,
326
- action: JSON.pretty_generate(action_payload),
327
- topic_id: topic_id,
328
- private: false
329
- )
271
+ def build_policy_resolver
272
+ Orchestration::PolicyResolver.new(@context)
330
273
  end
331
274
  end
332
275
  end
@@ -129,9 +129,12 @@ module Collavre
129
129
  tool_name = tool_call.name
130
130
  task = context&.dig(:task)
131
131
 
132
- # Check if this tool requires approval
132
+ # Check if this tool requires approval (dynamic McpTool or system tool)
133
133
  mcp_tool = McpTool.find_by(name: tool_name)
134
- return unless mcp_tool&.requires_approval?
134
+ system_tool_class = ToolMeta.registry.find { |klass| klass.tool_metadata[:name] == tool_name }
135
+ requires = mcp_tool&.requires_approval? ||
136
+ (system_tool_class.respond_to?(:requires_approval?) && system_tool_class.requires_approval?)
137
+ return unless requires
135
138
 
136
139
  # Check if we already have approval for this specific call (resume scenario)
137
140
  if task&.pending_tool_call.present?
@@ -159,9 +162,18 @@ module Collavre
159
162
  next unless role
160
163
 
161
164
  text = extract_message_text(message)
162
- next if text.blank?
165
+ image_sources = extract_image_sources(message)
163
166
 
164
- conversation.add_message(role:, content: text)
167
+ next if text.blank? && image_sources.empty?
168
+
169
+ if image_sources.any?
170
+ content = RubyLLM::Content.new(text.presence, image_sources)
171
+ conversation.add_message(role:, content: content)
172
+ else
173
+ next if text.blank?
174
+
175
+ conversation.add_message(role:, content: text)
176
+ end
165
177
  end
166
178
  end
167
179
 
@@ -177,6 +189,13 @@ module Collavre
177
189
  end
178
190
  end
179
191
 
192
+ def extract_image_sources(message)
193
+ parts = message[:parts] || message["parts"]
194
+ return [] if parts.nil?
195
+
196
+ Array(parts).filter_map { |part| part[:image] || part["image"] }
197
+ end
198
+
180
199
  def extract_message_text(message)
181
200
  parts = message[:parts] || message["parts"]
182
201
  return message[:text] || message["text"] if parts.nil?