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,240 @@
1
+ require "base64"
2
+ require "securerandom"
3
+ require "nokogiri"
4
+
5
+ module Collavre
6
+ # Converts between Markdown and HTML for creative descriptions.
7
+ #
8
+ # Extracted from CreativesHelper to keep conversion logic testable
9
+ # outside of view contexts and reusable from services (e.g. MarkdownImporter).
10
+ class MarkdownConverter
11
+ class << self
12
+ # Convert lightweight Markdown (links, bold, images) to HTML.
13
+ def markdown_to_html(text, image_refs = {})
14
+ return "" if text.nil?
15
+ html = text.dup
16
+
17
+ # Collect reference-style data-URI images: [alt]: <data:...>
18
+ html.gsub!(/^\s*\[([^\]]+)\]:\s*<\s*(data:image\/[^>]+)\s*>\s*$/) do
19
+ image_refs[$1] = $2.strip
20
+ ""
21
+ end
22
+
23
+ # Reference-style images: ![alt][ref]
24
+ html.gsub!(/(?<!\\)!\[([^\]]*)\]\[([^\]]+)\]/) do
25
+ if (data_url = image_refs[$2])
26
+ data_image_to_attachment(data_url, $1)
27
+ else
28
+ "![#{$1}][#{$2}]"
29
+ end
30
+ end
31
+
32
+ # Inline data-URI images: ![alt](data:...)
33
+ html.gsub!(/(?<!\\)!\[([^\]]*)\]\((data:image\/[^)]+)\)/) do
34
+ data_image_to_attachment($2, $1)
35
+ end
36
+
37
+ # Inline links: [text](url)
38
+ html.gsub!(/(?<!\\)\[([^\]]+)\]\(([^)]+)\)/) do
39
+ "<a href=\"#{$2}\">#{$1}</a>"
40
+ end
41
+
42
+ # Bold: **text** or __text__
43
+ html.gsub!(/(?<!\\)(\*\*|__)(.+?)\1/m) do
44
+ "<strong>#{$2}</strong>"
45
+ end
46
+
47
+ # Unescape backslash-escaped chars
48
+ html.gsub!(/\\([\\*_\[\]()!#~+\-])/, '\\1')
49
+ html.gsub!(/\\\\/, "\\")
50
+ html.strip!
51
+ html
52
+ end
53
+
54
+ # Convert HTML (links, bold, images, tables) back to Markdown.
55
+ def html_to_markdown(text)
56
+ return "" if text.nil?
57
+ markdown = text.dup
58
+ placeholders = {}
59
+ index = 0
60
+
61
+ # Tables → Markdown tables
62
+ markdown.gsub!(%r{<table\b[^>]*>.*?</table>}im) do |match|
63
+ token = "__TABLE#{index}__"; index += 1
64
+ placeholders[token] = table_to_markdown(match)
65
+ token
66
+ end
67
+
68
+ # Action-text attachments → data-URI images
69
+ markdown.gsub!(%r{<action-text-attachment ([^>]+)>(?:</action-text-attachment>)?}) do |_match|
70
+ attrs = Hash[$1.scan(/(\S+?)="([^"]*)"/)]
71
+ sgid = attrs["sgid"]
72
+ caption = attrs["caption"] || ""
73
+ if (blob = GlobalID::Locator.locate_signed(sgid, for: "attachable"))
74
+ data = Base64.strict_encode64(blob.download)
75
+ token = "__IMG#{index}__"; index += 1
76
+ placeholders[token] = "![#{caption}](data:#{blob.content_type};base64,#{data})"
77
+ token
78
+ else
79
+ ""
80
+ end
81
+ end
82
+
83
+ # Active Storage blob images → data-URI
84
+ markdown.gsub!(%r{<img [^>]*src=["'](/rails/active_storage/blobs/[^"']+)["'][^>]*alt=["']([^"']*)["'][^>]*>}) do |match|
85
+ blob_path = $1
86
+ alt_text = $2
87
+ if blob_path =~ %r{/rails/active_storage/blobs/(?:redirect|proxy)/([^/]+)/}
88
+ signed_id = $1
89
+ begin
90
+ blob = ActiveStorage::Blob.find_signed(signed_id)
91
+ data = Base64.strict_encode64(blob.download)
92
+ token = "__IMG#{index}__"; index += 1
93
+ placeholders[token] = "![#{alt_text}](data:#{blob.content_type};base64,#{data})"
94
+ token
95
+ rescue
96
+ match
97
+ end
98
+ else
99
+ match
100
+ end
101
+ end
102
+
103
+ # Inline data-URI images (src before alt)
104
+ markdown.gsub!(/<img [^>]*src=['"](data:[^'"]+)['"][^>]*alt=['"]([^'"]*)['"][^>]*>/) do
105
+ token = "__IMG#{index}__"; index += 1
106
+ placeholders[token] = "![#{$2}](#{$1})"
107
+ token
108
+ end
109
+
110
+ # Inline data-URI images (alt before src)
111
+ markdown.gsub!(/<img [^>]*alt=['"]([^'"]*)['"][^>]*src=['"](data:[^'"]+)['"][^>]*>/) do
112
+ token = "__IMG#{index}__"; index += 1
113
+ placeholders[token] = "![#{$1}](#{$2})"
114
+ token
115
+ end
116
+
117
+ # Links → [text](url)
118
+ markdown.gsub!(/<a [^>]*href=['"]([^'"]+)['"][^>]*>(.*?)<\/a>/m) do
119
+ inner = ActionView::Base.full_sanitizer.sanitize($2)
120
+ token = "__LINK#{index}__"; index += 1
121
+ placeholders[token] = "[#{inner}](#{$1})"
122
+ token
123
+ end
124
+
125
+ # Bold → **text**
126
+ markdown.gsub!(/<(strong|b)(?:\s+[^>]*)?>(.*?)<\/\1>/im) do
127
+ token = "__BOLD#{index}__"; index += 1
128
+ placeholders[token] = "**#{$2.strip}**"
129
+ token
130
+ end
131
+
132
+ # Escape markdown special chars in remaining text
133
+ markdown.gsub!(/([\\*\[\]()!#~+\-])/) { "\\#{$1}" }
134
+
135
+ # Restore placeholders
136
+ placeholders.each { |k, v| markdown.gsub!(k, v) }
137
+
138
+ # Strip remaining HTML tags
139
+ markdown.gsub!(/<[^>]+>/, "")
140
+ markdown
141
+ end
142
+
143
+ # Check whether text looks like a Markdown table block.
144
+ def table_block?(text)
145
+ lines = text.to_s.strip.split("\n")
146
+ return false if lines.length < 2
147
+
148
+ header_line = lines[0]
149
+ alignment_line = lines[1]
150
+ return false unless header_line.match?(/\A\|.*\|\z/)
151
+ return false unless alignment_line.match?(/\A\|[ \-:\|]+\|\z/)
152
+
153
+ true
154
+ end
155
+
156
+ # Convert an HTML <table> fragment to a Markdown table string.
157
+ def table_to_markdown(table_html)
158
+ fragment = Nokogiri::HTML::DocumentFragment.parse(table_html)
159
+ table = fragment.at_css("table")
160
+ return "" unless table
161
+
162
+ header_row = table.at_css("thead tr") || table.css("tr").first
163
+ return "" unless header_row
164
+
165
+ header_cells = header_row.css("th,td")
166
+ headers = header_cells.map { |cell| escape_table_cell(html_to_markdown(cell.inner_html).strip) }
167
+ alignments = header_cells.map { |cell| alignment_from_cell(cell) }
168
+
169
+ body_rows = table.css("tbody tr")
170
+ if body_rows.empty?
171
+ all_rows = table.css("tr")
172
+ body_rows = all_rows.drop(1)
173
+ end
174
+
175
+ body_lines = body_rows.map do |row|
176
+ cells = row.css("th,td").map { |cell| escape_table_cell(html_to_markdown(cell.inner_html).strip) }
177
+ normalized = pad_cells(cells, headers.length)
178
+ "| #{normalized.join(' | ')} |"
179
+ end
180
+
181
+ alignment_cells = pad_cells(alignments, headers.length).map { |align| alignment_marker(align) }
182
+ header_line = "| #{headers.map(&:strip).join(' | ')} |"
183
+ alignment_line = "| #{alignment_cells.join(' | ')} |"
184
+
185
+ ([ header_line, alignment_line ] + body_lines).join("\n")
186
+ end
187
+
188
+ # Convert a data-URI image to an Active Storage attachment <img> tag.
189
+ def data_image_to_attachment(data_url, alt)
190
+ if data_url =~ %r{\Adata:(image/[\w.+-]+);base64,(.+)\z}
191
+ content_type = Regexp.last_match(1)
192
+ data = Base64.decode64(Regexp.last_match(2))
193
+ ext = Mime::Type.lookup(content_type).symbol.to_s
194
+ filename = "import-#{SecureRandom.hex}.#{ext}"
195
+ blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(data), filename: filename, content_type: content_type)
196
+ "<img src=\"#{Rails.application.routes.url_helpers.rails_blob_url(blob, only_path: true)}\" alt=\"#{alt}\" />"
197
+ else
198
+ "<img src=\"#{data_url}\" alt=\"#{alt}\" />"
199
+ end
200
+ end
201
+
202
+ private
203
+
204
+ def escape_table_cell(text)
205
+ text.to_s.gsub(/(?<!\\)\|/, '\\|')
206
+ end
207
+
208
+ def alignment_from_cell(cell)
209
+ style = cell["style"].to_s
210
+ align = cell["align"].to_s
211
+ case
212
+ when style =~ /text-align\s*:\s*center/i || align =~ /center/i
213
+ :center
214
+ when style =~ /text-align\s*:\s*right/i || align =~ /right/i
215
+ :right
216
+ when style =~ /text-align\s*:\s*left/i || align =~ /left/i
217
+ :left
218
+ else
219
+ nil
220
+ end
221
+ end
222
+
223
+ def alignment_marker(alignment)
224
+ case alignment
225
+ when :center then ":---:"
226
+ when :right then "---:"
227
+ when :left then ":---"
228
+ else "---"
229
+ end
230
+ end
231
+
232
+ def pad_cells(cells, expected_length)
233
+ values = cells.dup
234
+ values = values.first(expected_length)
235
+ values.fill("", values.length...expected_length)
236
+ values
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,63 @@
1
+ module Collavre
2
+ # Centralized mention parsing and resolution.
3
+ # All @mention logic should go through this module so changes
4
+ # to the mention format only need to be made in one place.
5
+ module MentionParser
6
+ # Characters allowed before @ in mentions (besides start-of-text)
7
+ MENTION_PREFIX_CHARS = /[\s:.,;\n\r]/
8
+
9
+ # Canonical mention: @name: (with colon separator)
10
+ # Matches at start of text or after whitespace/punctuation/newline
11
+ MENTION_PATTERN = /(?:\A|(?<=#{MENTION_PREFIX_CHARS}))@([^:]+?):\s*/
12
+
13
+ # Mention without colon: @name followed by whitespace (start-of-text only
14
+ # to avoid false positives like email addresses)
15
+ MENTION_LOOSE_PATTERN = /\A@(\S+)\s+/
16
+
17
+ # Scan pattern: finds all @name: mentions anywhere in text
18
+ MENTION_SCAN_PATTERN = /(?:^|(?<=#{MENTION_PREFIX_CHARS}))@([^:]+?):/
19
+
20
+ # Extract the first mentioned name from text (returns nil if no mention found)
21
+ def self.extract_name(text)
22
+ return nil if text.blank?
23
+
24
+ match = text.match(MENTION_PATTERN) || text.match(MENTION_LOOSE_PATTERN)
25
+ match ? match[1].strip : nil
26
+ end
27
+
28
+ # Extract all mentioned names from text
29
+ def self.extract_all_names(text)
30
+ return [] if text.blank?
31
+
32
+ text.scan(MENTION_SCAN_PATTERN).flatten.map(&:strip).uniq
33
+ end
34
+
35
+ # Find a User by case-insensitive name match
36
+ def self.find_user_by_name(name)
37
+ return nil if name.blank?
38
+
39
+ User.where("LOWER(name) = ?", name.strip.downcase).first
40
+ end
41
+
42
+ # Extract mention and resolve to a User in one step
43
+ def self.resolve_user(text)
44
+ name = extract_name(text)
45
+ name ? find_user_by_name(name) : nil
46
+ end
47
+
48
+ # Resolve all mentioned users from text (finds mentions anywhere)
49
+ def self.resolve_all_users(text)
50
+ extract_all_names(text).filter_map { |name| find_user_by_name(name) }.uniq
51
+ end
52
+
53
+ # Strip self-mention prefix from text (both @name: and @name formats)
54
+ def self.strip_self_mention(text, agent_name)
55
+ return text if text.blank? || agent_name.blank?
56
+
57
+ escaped = Regexp.escape(agent_name)
58
+ text
59
+ .sub(/\A@#{escaped}:\s*/i, "")
60
+ .sub(/\A@#{escaped}\s+/i, "")
61
+ end
62
+ end
63
+ end
@@ -19,10 +19,11 @@ module Collavre
19
19
  "read" => "reference" # Can request information from
20
20
  }.freeze
21
21
 
22
- def initialize(agent:, creative:, sender: nil)
22
+ def initialize(agent:, creative:, sender: nil, policy_resolver: nil)
23
23
  @agent = agent
24
24
  @creative = creative
25
25
  @sender = sender
26
+ @policy_resolver = policy_resolver
26
27
  end
27
28
 
28
29
  def a2a_request?
@@ -45,6 +46,7 @@ module Collavre
45
46
  # Generates markdown-formatted collaboration section for system prompt
46
47
  def to_collaboration_prompt
47
48
  sections = []
49
+ collab = collaboration_policy_config
48
50
 
49
51
  # Add A2A request context if this is an agent-to-agent call
50
52
  if a2a_request?
@@ -52,9 +54,16 @@ module Collavre
52
54
  sections << ""
53
55
  sections << I18n.t("collavre.ai_agent.a2a.request_description",
54
56
  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")
57
+ sections << (collab["a2a_focus_instruction"] || I18n.t("collavre.ai_agent.a2a.focus_instruction"))
58
+ sections << (collab["a2a_completion_instruction"] ||
59
+ I18n.t("collavre.ai_agent.a2a.completion_instruction",
60
+ sender_name: @sender["name"]))
61
+ sections << (collab["a2a_followup_instruction"] || I18n.t("collavre.ai_agent.a2a.followup_instruction"))
62
+ sections << ""
63
+ elsif @sender
64
+ # Human-triggered request: instruct agent to report back
65
+ sections << I18n.t("collavre.ai_agent.a2a.human_completion_instruction",
66
+ sender_name: @sender["name"])
58
67
  sections << ""
59
68
  end
60
69
 
@@ -100,10 +109,13 @@ module Collavre
100
109
  end
101
110
 
102
111
  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")
112
+ sections << (collab["mention_rule"] || I18n.t("collavre.ai_agent.collaboration.mention_rule"))
113
+ sections << (collab["confidence_rule"] || I18n.t("collavre.ai_agent.collaboration.confidence_rule"))
114
+ if @policy_resolver&.self_reflection_enabled?
115
+ sections << I18n.t("collavre.ai_agent.collaboration.confidence_format_instruction")
116
+ end
117
+ sections << (collab["escalation_rule"] || I18n.t("collavre.ai_agent.collaboration.escalation_rule"))
118
+ sections << (collab["review_rule"] || I18n.t("collavre.ai_agent.collaboration.review_rule"))
107
119
  sections << ""
108
120
 
109
121
  sections.join("\n")
@@ -196,6 +208,10 @@ module Collavre
196
208
  "review_hint" => I18n.t("collavre.ai_agent.collaboration.review_rule")
197
209
  }
198
210
  end
211
+
212
+ def collaboration_policy_config
213
+ @policy_resolver&.collaboration_config || {}
214
+ end
199
215
  end
200
216
  end
201
217
  end
@@ -23,6 +23,7 @@ module Collavre
23
23
  updated = Task.where(id: task.id, status: "queued").update_all(status: "pending")
24
24
  if updated > 0
25
25
  task.reload
26
+ cleanup_waiting_notices!(task)
26
27
  refresh_deferred_context!(task)
27
28
 
28
29
  if task.status == "cancelled"
@@ -35,6 +36,19 @@ module Collavre
35
36
  end
36
37
  end
37
38
 
39
+ # Remove waiting notice comments (system messages) for this task's creative/topic.
40
+ def self.cleanup_waiting_notices!(task)
41
+ context = task.trigger_event_payload
42
+ creative_id = context&.dig("creative", "id")
43
+ topic_id = context&.dig("topic", "id")
44
+ return unless creative_id
45
+
46
+ Comment.where(creative_id: creative_id, topic_id: topic_id, user_id: nil)
47
+ .where("content LIKE ?", "⏳%")
48
+ .destroy_all
49
+ end
50
+ private_class_method :cleanup_waiting_notices!
51
+
38
52
  # Refresh trigger_event_payload so the deferred agent sees the latest
39
53
  # conversation state instead of the stale snapshot from enqueue time.
40
54
  # Skips AI agent's own comments to prevent self-response loops.
@@ -47,7 +61,7 @@ module Collavre
47
61
  topic_id = context.dig("topic", "id")
48
62
  scope = Comment
49
63
  .where(creative_id: creative_id, topic_id: topic_id, private: false)
50
- .where.not(user_id: task.agent_id)
64
+ .where.not(user_id: [ task.agent_id, nil ])
51
65
  .order(created_at: :desc)
52
66
  latest_comment = scope.first
53
67
 
@@ -110,33 +124,68 @@ module Collavre
110
124
 
111
125
  def enqueue_jobs(decisions)
112
126
  decisions.filter_map do |decision|
127
+ agent = decision[:agent]
128
+ log_decision(decision)
129
+
113
130
  case decision[:timing]
114
131
  when :immediate
115
- AiAgentJob.perform_later(decision[:agent].id, @event_name, @context)
116
- decision[:agent]
132
+ AiAgentJob.perform_later(agent.id, @event_name, @context)
133
+ agent
117
134
  when :deferred
118
135
  Task.create!(
119
136
  name: "Response to #{@event_name}",
120
137
  status: "queued",
121
138
  trigger_event_name: @event_name,
122
139
  trigger_event_payload: @context,
123
- agent: decision[:agent],
140
+ agent: agent,
124
141
  topic_id: @context.dig("topic", "id")
125
142
  )
126
- decision[:agent]
143
+ post_waiting_notice(agent, decision)
144
+ agent
127
145
  when :delayed
128
146
  AiAgentJob.set(wait: decision[:delay]).perform_later(
129
- decision[:agent].id, @event_name, @context
147
+ agent.id, @event_name, @context
130
148
  )
131
- decision[:agent]
149
+ post_waiting_notice(agent, decision)
150
+ agent
132
151
  when :rejected
133
- Rails.logger.info(
134
- "[Orchestrator] Agent #{decision[:agent].id} rejected: #{decision[:reason]}"
135
- )
136
152
  nil
137
153
  end
138
154
  end
139
155
  end
156
+
157
+ def log_decision(decision)
158
+ agent = decision[:agent]
159
+ topic_id = @context.dig("topic", "id") || "main"
160
+ detail = [ decision[:timing], decision[:reason] ].compact.join(": ")
161
+ detail += " #{decision[:delay]}s" if decision[:delay]
162
+ Rails.logger.info(
163
+ "[Orchestrator] Agent #{agent.id} (#{agent.name}) → #{detail} " \
164
+ "(event=#{@event_name}, topic=#{topic_id})"
165
+ )
166
+ end
167
+
168
+ def post_waiting_notice(agent, decision)
169
+ creative_id = @context.dig("creative", "id")
170
+ topic_id = @context.dig("topic", "id")
171
+ return unless creative_id
172
+
173
+ creative = Creative.find_by(id: creative_id)
174
+ return unless creative
175
+
176
+ reason_key = decision[:reason] || :unknown
177
+ reason_text = I18n.t(
178
+ "collavre.orchestration.waiting_reasons.#{reason_key}",
179
+ default: reason_key.to_s.humanize
180
+ )
181
+
182
+ creative.comments.create!(
183
+ content: I18n.t("collavre.orchestration.waiting_notice", reason: reason_text),
184
+ topic_id: topic_id,
185
+ private: false,
186
+ skip_default_user: true
187
+ )
188
+ end
140
189
  end
141
190
  end
142
191
  end
@@ -67,9 +67,12 @@ module Collavre
67
67
  Rails.cache.write(key, interactions, expires_in: CACHE_EXPIRY)
68
68
  end
69
69
 
70
- # Record a task creation for creative retry detection
71
- def record_task(creative_id, agent_id)
72
- key = creative_tasks_key(creative_id)
70
+ # Record a task creation for creative retry detection (per-topic)
71
+ # Skips recording for user-initiated messages (only agent-to-agent counts as potential loop)
72
+ def record_task(creative_id, agent_id, topic_id: nil, triggered_by_user: false)
73
+ return if triggered_by_user
74
+
75
+ key = topic_tasks_key(creative_id, topic_id)
73
76
  tasks = Rails.cache.read(key) || []
74
77
  tasks << { at: Time.current.to_i, agent_id: agent_id }
75
78
 
@@ -136,12 +139,13 @@ module Collavre
136
139
  safe_result
137
140
  end
138
141
 
139
- # Detect too many tasks on same creative
142
+ # Detect too many tasks on same topic (per-topic, not per-creative)
140
143
  def check_creative_retry
141
144
  creative_id = @context.dig("creative", "id")
145
+ topic_id = @context.dig("topic", "id")
142
146
  return safe_result unless creative_id
143
147
 
144
- key = creative_tasks_key(creative_id)
148
+ key = topic_tasks_key(creative_id, topic_id)
145
149
  tasks = Rails.cache.read(key) || []
146
150
 
147
151
  # Filter to window
@@ -154,6 +158,7 @@ module Collavre
154
158
  reason: :creative_retry_exceeded,
155
159
  details: {
156
160
  creative_id: creative_id,
161
+ topic_id: topic_id,
157
162
  task_count: recent_tasks.size,
158
163
  threshold: creative_retry_threshold,
159
164
  window_minutes: @config["creative_retry_window_minutes"]
@@ -253,8 +258,8 @@ module Collavre
253
258
  "#{CACHE_PREFIX}:ping_pong:#{sorted[0]}-#{sorted[1]}:#{creative_id}"
254
259
  end
255
260
 
256
- def creative_tasks_key(creative_id)
257
- "#{CACHE_PREFIX}:creative:#{creative_id}:tasks"
261
+ def topic_tasks_key(creative_id, topic_id)
262
+ "#{CACHE_PREFIX}:creative:#{creative_id}:topic:#{topic_id || "main"}:tasks"
258
263
  end
259
264
 
260
265
  def token_usage_key(agent_id)
@@ -34,7 +34,7 @@ module Collavre
34
34
  "max_retries" => 3,
35
35
  "retry_delay_seconds" => 5,
36
36
  # Loop breaker settings
37
- "loop_breaker_enabled" => false,
37
+ "loop_breaker_enabled" => true,
38
38
  "ping_pong_threshold" => 5, # Max back-and-forth between same agents
39
39
  "creative_retry_threshold" => 10, # Max tasks on same creative in time window
40
40
  "creative_retry_window_minutes" => 30,
@@ -43,10 +43,19 @@ module Collavre
43
43
  "token_spike_window_minutes" => 10
44
44
  },
45
45
  "stuck_detection" => {
46
- "enabled" => true,
46
+ "enabled" => false,
47
47
  "task_stuck_threshold_minutes" => 30, # Task running for > N minutes
48
48
  "creative_stall_threshold_minutes" => 120, # Creative no progress for > N minutes
49
49
  "create_system_comment" => true # Create system comment on escalation
50
+ },
51
+ "collaboration" => {
52
+ "a2a_focus_instruction" => nil, # nil = locale default
53
+ "a2a_completion_instruction" => nil,
54
+ "a2a_followup_instruction" => nil,
55
+ "mention_rule" => nil,
56
+ "confidence_rule" => nil,
57
+ "escalation_rule" => nil,
58
+ "review_rule" => nil
50
59
  }
51
60
  }.freeze
52
61
 
@@ -135,6 +144,11 @@ module Collavre
135
144
  }
136
145
  end
137
146
 
147
+ # Collaboration config
148
+ def collaboration_config
149
+ @collaboration_config ||= merge_policies("collaboration")
150
+ end
151
+
138
152
  # Stuck detection settings
139
153
  def stuck_detection_enabled?
140
154
  stuck_detection_config["enabled"] == true
@@ -77,7 +77,7 @@ module Collavre
77
77
  if topic_max && @context.key?("topic")
78
78
  topic_id = @context.dig("topic", "id")
79
79
  if (Task.running_for_topic(topic_id).count + topic_immediate_count) >= topic_max
80
- return deferred_decision(agent)
80
+ return deferred_decision(agent, :topic_concurrency)
81
81
  end
82
82
  end
83
83
 
@@ -101,10 +101,11 @@ module Collavre
101
101
  }
102
102
  end
103
103
 
104
- def deferred_decision(agent)
104
+ def deferred_decision(agent, reason = nil)
105
105
  {
106
106
  agent: agent,
107
- timing: :deferred
107
+ timing: :deferred,
108
+ reason: reason
108
109
  }
109
110
  end
110
111
 
@@ -267,7 +267,7 @@ module Collavre
267
267
  [
268
268
  "collavre.stuck_detection.creative_stalled",
269
269
  {
270
- creative_title: creative.description&.truncate(50) || "Untitled",
270
+ creative_title: creative.creative_snippet,
271
271
  hours: hours_stuck,
272
272
  progress: ((creative.progress || 0) * 100).round
273
273
  }
@@ -55,12 +55,7 @@ module Collavre
55
55
  content = chat_context["content"]
56
56
  return nil unless content
57
57
 
58
- # Canonical mention format: @name: (with colon)
59
- match = content.match(/\A@([^:]+?):\s*/)
60
- return nil unless match
61
-
62
- name = match[1].strip
63
- user = User.where("LOWER(name) = ?", name.downcase).first
58
+ user = MentionParser.resolve_user(content)
64
59
  user&.as_json(only: [ :id, :name, :email ])
65
60
  end
66
61
  end