collavre_openclaw 0.2.0 → 0.2.2

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.
@@ -0,0 +1,220 @@
1
+ module CollavreOpenclaw
2
+ # Handles proactive (unsolicited) chat events from OpenClaw Gateway.
3
+ #
4
+ # Proactive messages arrive when the Gateway agent initiates communication
5
+ # (e.g., cron jobs, heartbeats, reminders) without a prior request from Collavre.
6
+ #
7
+ # Events arrive as streaming deltas followed by a final event, just like
8
+ # regular chat responses. This handler buffers deltas per runId and dispatches
9
+ # the complete message to CallbackProcessorJob on final.
10
+ #
11
+ # Thread safety: called from the EventMachine reactor thread. All operations
12
+ # should be non-blocking. Job enqueueing is safe from any thread.
13
+ #
14
+ # Stale buffer cleanup: buffers older than BUFFER_TTL are periodically purged
15
+ # to prevent memory leaks when the Gateway never sends a final/error event.
16
+ class ProactiveMessageHandler
17
+ # Maximum age (seconds) for a buffer before it's considered stale and purged.
18
+ # Generous timeout for long-running AI responses.
19
+ BUFFER_TTL = 30.minutes.to_i
20
+
21
+ # How often (seconds) to check for stale buffers.
22
+ SWEEP_INTERVAL = 60
23
+
24
+ def initialize
25
+ @buffers = {} # runId → { text:, session_key:, connection_owner_id:, created_at: }
26
+ @mutex = Mutex.new
27
+ @last_sweep_at = monotonic_now
28
+ end
29
+
30
+ # Process a single chat event payload from the WebSocket client.
31
+ # Called by WebsocketClient#on_proactive_message.
32
+ #
33
+ # @param user [User] the user who owns the Gateway connection
34
+ # @param payload [Hash] the chat event payload with :runId, :state, :message, :sessionKey
35
+ def handle(user, payload)
36
+ run_id = payload[:runId]
37
+ return unless run_id
38
+
39
+ sweep_stale_buffers_if_needed!
40
+
41
+ state = payload[:state]&.to_s
42
+ session_key = payload[:sessionKey]
43
+
44
+ case state
45
+ when "delta"
46
+ buffer_delta(run_id, user, session_key, payload)
47
+ when "final"
48
+ handle_final(run_id, user, session_key, payload)
49
+ when "error"
50
+ handle_error(run_id, user, payload)
51
+ when "aborted"
52
+ cleanup(run_id)
53
+ else
54
+ # Unknown state — log and ignore
55
+ Rails.logger.debug("[CollavreOpenclaw::Proactive] Unknown state '#{state}' for runId=#{run_id}")
56
+ end
57
+ end
58
+
59
+ # Parse a session key into its component parts.
60
+ # Format: agent:<agent_id>:collavre:<user_id>[:creative:<id>][:topic:<id>]
61
+ #
62
+ # @param session_key [String]
63
+ # @return [Hash] with :agent_id, :user_id, :creative_id, :topic_id keys
64
+ def self.parse_session_key(session_key)
65
+ return {} unless session_key.present?
66
+
67
+ result = {}
68
+ parts = session_key.split(":")
69
+
70
+ # Parse key:value pairs
71
+ i = 0
72
+ while i < parts.length - 1
73
+ key = parts[i]
74
+ value = parts[i + 1]
75
+
76
+ case key
77
+ when "agent"
78
+ result[:agent_id] = value
79
+ i += 2
80
+ when "collavre"
81
+ result[:user_id] = value.to_i
82
+ i += 2
83
+ when "creative"
84
+ result[:creative_id] = value.to_i
85
+ i += 2
86
+ when "topic"
87
+ result[:topic_id] = value.to_i
88
+ i += 2
89
+ else
90
+ i += 1
91
+ end
92
+ end
93
+
94
+ result
95
+ end
96
+
97
+ private
98
+
99
+ def buffer_delta(run_id, user, session_key, payload)
100
+ text = extract_text(payload)
101
+ return unless text.present?
102
+
103
+ @mutex.synchronize do
104
+ buffer = @buffers[run_id] ||= {
105
+ text: +"",
106
+ session_key: session_key,
107
+ # Note: connection_owner_id is for debugging only.
108
+ # The actual agent user_id comes from session_key parsing.
109
+ connection_owner_id: user.id,
110
+ created_at: monotonic_now
111
+ }
112
+ buffer[:text] << text
113
+ end
114
+ end
115
+
116
+ def handle_final(run_id, user, session_key, payload)
117
+ final_text = extract_text(payload)
118
+
119
+ buffer = @mutex.synchronize { @buffers.delete(run_id) }
120
+
121
+ # Prefer final text (complete message) over buffered deltas (fragments).
122
+ # Fall back to buffered deltas only when final text is empty.
123
+ content = if final_text.present?
124
+ final_text
125
+ elsif buffer
126
+ buffer[:text]
127
+ end
128
+
129
+ return unless content.present?
130
+
131
+ # Use session_key from final event, falling back to buffered
132
+ effective_session_key = session_key || buffer&.dig(:session_key)
133
+ context = self.class.parse_session_key(effective_session_key)
134
+
135
+ dispatch_to_job(user, content, context)
136
+ end
137
+
138
+ def handle_error(run_id, _user, payload)
139
+ error_msg = payload[:errorMessage] || payload.dig(:message, :content) || "Unknown error"
140
+ Rails.logger.error("[CollavreOpenclaw::Proactive] Error for runId=#{run_id}: #{error_msg}")
141
+ cleanup(run_id)
142
+ end
143
+
144
+ def cleanup(run_id)
145
+ @mutex.synchronize { @buffers.delete(run_id) }
146
+ end
147
+
148
+ def extract_text(payload)
149
+ message = payload[:message]
150
+ return nil unless message.is_a?(Hash)
151
+
152
+ content = message[:content]
153
+ case content
154
+ when String
155
+ content
156
+ when Array
157
+ content.filter_map { |c| c[:text] if c[:type] == "text" }.join
158
+ end
159
+ end
160
+
161
+ # Purge buffers older than BUFFER_TTL. Runs at most once per SWEEP_INTERVAL
162
+ # to avoid scanning on every event.
163
+ def sweep_stale_buffers_if_needed!
164
+ now = monotonic_now
165
+ return if (now - @last_sweep_at) < SWEEP_INTERVAL
166
+
167
+ @last_sweep_at = now
168
+ cutoff = now - BUFFER_TTL
169
+
170
+ @mutex.synchronize do
171
+ stale_ids = @buffers.each_with_object([]) do |(run_id, buf), ids|
172
+ ids << run_id if buf[:created_at] && buf[:created_at] < cutoff
173
+ end
174
+
175
+ stale_ids.each do |run_id|
176
+ @buffers.delete(run_id)
177
+ Rails.logger.warn(
178
+ "[CollavreOpenclaw::Proactive] Purged stale buffer for runId=#{run_id} " \
179
+ "(older than #{BUFFER_TTL}s)"
180
+ )
181
+ end
182
+ end
183
+ end
184
+
185
+ def monotonic_now
186
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
187
+ end
188
+
189
+ def dispatch_to_job(user, content, context)
190
+ creative_id = context[:creative_id]
191
+
192
+ unless creative_id.present?
193
+ Rails.logger.warn("[CollavreOpenclaw::Proactive] No creative_id in session key, skipping. Context: #{context}")
194
+ return
195
+ end
196
+
197
+ # Use user_id from session_key (the actual agent) rather than
198
+ # the connection owner (which may be a different agent sharing the gateway)
199
+ agent_user_id = context[:user_id] || user.id
200
+
201
+ Rails.logger.info(
202
+ "[CollavreOpenclaw::Proactive] Dispatching proactive message " \
203
+ "(user=#{agent_user_id}, creative=#{creative_id}, topic=#{context[:topic_id]})"
204
+ )
205
+
206
+ CallbackProcessorJob.perform_later(
207
+ agent_user_id,
208
+ {
209
+ "type" => "proactive",
210
+ "content" => content,
211
+ "creative_id" => creative_id,
212
+ "context" => {
213
+ "creative_id" => creative_id,
214
+ "thread_id" => context[:topic_id]
215
+ }
216
+ }
217
+ )
218
+ end
219
+ end
220
+ end