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.
- checksums.yaml +4 -4
- data/app/services/collavre_openclaw/ai_client_extension.rb +3 -4
- data/app/services/collavre_openclaw/connection_manager.rb +192 -0
- data/app/services/collavre_openclaw/em_reactor.rb +52 -0
- data/app/services/collavre_openclaw/openclaw_adapter.rb +240 -162
- data/app/services/collavre_openclaw/proactive_message_handler.rb +220 -0
- data/app/services/collavre_openclaw/websocket_client.rb +554 -0
- data/lib/collavre_openclaw/configuration.rb +16 -0
- data/lib/collavre_openclaw/engine.rb +14 -0
- data/lib/collavre_openclaw/errors.rb +6 -0
- data/lib/collavre_openclaw/version.rb +1 -1
- data/lib/collavre_openclaw.rb +1 -0
- metadata +34 -1
|
@@ -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
|