collavre_openclaw 0.2.1 → 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 +213 -178
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff70659073539f4ad6104e96ed6c9a85f4b639cf30e6bfdb6e56166d1e5cdf1d
|
|
4
|
+
data.tar.gz: c96bff5a56ef9f74fc011d649a3be81cbba192c3a3fabbac13281d48929d2d11
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a3ba72b2e7fd8de71f8276858c8b81ef3767203c6769ab456d837bb9b1dc94f78c6a2584308582dcdf439e48c11d098d6d3673aecc0e34dd01775a5f9263334d
|
|
7
|
+
data.tar.gz: eddfebd33fbce4ffd79eb80b33d7715e46706947973b4c406b7d866ff98902de24e9bd8ddaa7addf91c6fad6d071aed195f0dbd36800e59a3fbc832feb5e10cd
|
|
@@ -19,7 +19,7 @@ module CollavreOpenclaw
|
|
|
19
19
|
adapter_class = self.class.adapter_registry[normalized_vendor]
|
|
20
20
|
|
|
21
21
|
if adapter_class
|
|
22
|
-
# Use the custom adapter
|
|
22
|
+
# Use the custom adapter (tools not supported for OpenClaw)
|
|
23
23
|
user = context&.dig(:user)
|
|
24
24
|
adapter = adapter_class.new(
|
|
25
25
|
user: user,
|
|
@@ -31,15 +31,14 @@ module CollavreOpenclaw
|
|
|
31
31
|
error_message = nil
|
|
32
32
|
|
|
33
33
|
begin
|
|
34
|
-
response_content = adapter.chat(contents,
|
|
34
|
+
response_content = adapter.chat(contents, &block)
|
|
35
35
|
rescue StandardError => e
|
|
36
36
|
error_message = e.message
|
|
37
37
|
raise
|
|
38
38
|
ensure
|
|
39
|
-
# Log the interaction just like AiClient does
|
|
40
39
|
log_interaction(
|
|
41
40
|
messages: Array(contents),
|
|
42
|
-
tools:
|
|
41
|
+
tools: [],
|
|
43
42
|
response_content: response_content,
|
|
44
43
|
error_message: error_message,
|
|
45
44
|
input_tokens: nil,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
module CollavreOpenclaw
|
|
2
|
+
# Manages WebSocket connections to OpenClaw Gateways.
|
|
3
|
+
#
|
|
4
|
+
# Singleton: use ConnectionManager.instance
|
|
5
|
+
#
|
|
6
|
+
# Features:
|
|
7
|
+
# - Lazy connect (creates connection on first use)
|
|
8
|
+
# - Connection sharing (same gateway_url → same connection)
|
|
9
|
+
# - Idle timeout (disconnects after inactivity)
|
|
10
|
+
# - Thread-safe access
|
|
11
|
+
# - Graceful shutdown
|
|
12
|
+
#
|
|
13
|
+
# Multiple AI agents using the same Gateway share a single WebSocket
|
|
14
|
+
# connection, reducing resource usage and connection overhead.
|
|
15
|
+
class ConnectionManager
|
|
16
|
+
include Singleton
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@connections = {} # gateway_url → WebsocketClient
|
|
20
|
+
@gateway_users = {} # gateway_url → Set<user_id> (users sharing this gateway)
|
|
21
|
+
@user_gateways = {} # user_id → gateway_url (reverse lookup)
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@idle_checker = nil
|
|
24
|
+
@proactive_handler = nil
|
|
25
|
+
@proactive_message_handler = ProactiveMessageHandler.new
|
|
26
|
+
setup_default_proactive_handler!
|
|
27
|
+
start_idle_checker!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get or create a WebSocket connection for a user.
|
|
31
|
+
# Users with the same gateway_url share a single connection.
|
|
32
|
+
#
|
|
33
|
+
# @param user [User] must respond to #gateway_url and #llm_api_key
|
|
34
|
+
# @return [WebsocketClient]
|
|
35
|
+
def connection_for(user)
|
|
36
|
+
gateway_url = user.gateway_url.to_s.strip
|
|
37
|
+
if gateway_url.blank?
|
|
38
|
+
Rails.logger.warn("[CollavreOpenclaw::ConnectionManager] User #{user.id} has blank gateway_url, cannot create connection")
|
|
39
|
+
return nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
client = @connections[gateway_url]
|
|
44
|
+
|
|
45
|
+
if client.nil?
|
|
46
|
+
client = WebsocketClient.new(user: user)
|
|
47
|
+
client.on_proactive_message(&@proactive_handler) if @proactive_handler
|
|
48
|
+
@connections[gateway_url] = client
|
|
49
|
+
@gateway_users[gateway_url] = Set.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Track this user as using this gateway
|
|
53
|
+
@gateway_users[gateway_url].add(user.id)
|
|
54
|
+
@user_gateways[user.id] = gateway_url
|
|
55
|
+
|
|
56
|
+
client
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Disconnect a specific user from their gateway connection.
|
|
61
|
+
# The connection is only closed if no other users are sharing it.
|
|
62
|
+
def disconnect(user)
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
gateway_url = @user_gateways.delete(user.id)
|
|
65
|
+
return unless gateway_url
|
|
66
|
+
|
|
67
|
+
users = @gateway_users[gateway_url]
|
|
68
|
+
users&.delete(user.id)
|
|
69
|
+
|
|
70
|
+
# Only disconnect if no users are sharing this gateway
|
|
71
|
+
if users.nil? || users.empty?
|
|
72
|
+
client = @connections.delete(gateway_url)
|
|
73
|
+
@gateway_users.delete(gateway_url)
|
|
74
|
+
client&.disconnect!
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Disconnect all connections (for app shutdown)
|
|
80
|
+
def disconnect_all
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
@connections.each_value(&:disconnect!)
|
|
83
|
+
@connections.clear
|
|
84
|
+
@gateway_users.clear
|
|
85
|
+
@user_gateways.clear
|
|
86
|
+
end
|
|
87
|
+
stop_idle_checker!
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Number of active connections
|
|
91
|
+
def connected_count
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
@connections.count { |_, c| c.connected? }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Status summary
|
|
98
|
+
def status
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
states = @connections.values.group_by(&:state)
|
|
101
|
+
{
|
|
102
|
+
connected: states[:connected]&.size || 0,
|
|
103
|
+
connecting: states[:connecting]&.size || 0,
|
|
104
|
+
reconnecting: states[:reconnecting]&.size || 0,
|
|
105
|
+
disconnected: states[:disconnected]&.size || 0,
|
|
106
|
+
total_connections: @connections.size,
|
|
107
|
+
total_users: @user_gateways.size
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Register a proactive message handler for all connections.
|
|
113
|
+
# New connections will also get this handler.
|
|
114
|
+
def on_proactive_message(&handler)
|
|
115
|
+
@mutex.synchronize do
|
|
116
|
+
@proactive_handler = handler
|
|
117
|
+
@connections.each_value do |client|
|
|
118
|
+
client.on_proactive_message(&handler)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Get the number of users sharing a specific gateway
|
|
124
|
+
def users_for_gateway(gateway_url)
|
|
125
|
+
@mutex.synchronize do
|
|
126
|
+
@gateway_users[gateway_url]&.size || 0
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Check if a user has an active connection
|
|
131
|
+
def user_connected?(user)
|
|
132
|
+
@mutex.synchronize do
|
|
133
|
+
gateway_url = @user_gateways[user.id]
|
|
134
|
+
return false unless gateway_url
|
|
135
|
+
@connections[gateway_url]&.connected? || false
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# Set up the default proactive message handler that dispatches
|
|
142
|
+
# unsolicited chat events to CallbackProcessorJob.
|
|
143
|
+
def setup_default_proactive_handler!
|
|
144
|
+
handler = @proactive_message_handler
|
|
145
|
+
on_proactive_message do |user, payload|
|
|
146
|
+
handler.handle(user, payload)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def start_idle_checker!
|
|
151
|
+
@idle_checker_stop = false
|
|
152
|
+
@idle_checker = Thread.new do
|
|
153
|
+
Thread.current.name = "openclaw-idle-checker"
|
|
154
|
+
loop do
|
|
155
|
+
break if @idle_checker_stop
|
|
156
|
+
sleep 1 # Check stop flag every second
|
|
157
|
+
@idle_check_counter = (@idle_check_counter || 0) + 1
|
|
158
|
+
next unless @idle_check_counter >= 60 # Run check every ~60 seconds
|
|
159
|
+
@idle_check_counter = 0
|
|
160
|
+
check_idle_connections!
|
|
161
|
+
rescue => e
|
|
162
|
+
Rails.logger.error("[CollavreOpenclaw::ConnectionManager] Idle checker error: #{e.message}")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def stop_idle_checker!
|
|
168
|
+
@idle_checker_stop = true
|
|
169
|
+
@idle_checker&.join(5)
|
|
170
|
+
@idle_checker = nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def check_idle_connections!
|
|
174
|
+
timeout = CollavreOpenclaw.config.ws_idle_timeout
|
|
175
|
+
|
|
176
|
+
@mutex.synchronize do
|
|
177
|
+
idle_gateways = @connections.each_with_object([]) do |(gateway_url, client), urls|
|
|
178
|
+
urls << gateway_url if client.connected? && client.idle_seconds > timeout
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
idle_gateways.each do |gateway_url|
|
|
182
|
+
client = @connections.delete(gateway_url)
|
|
183
|
+
user_ids = @gateway_users.delete(gateway_url) || []
|
|
184
|
+
user_ids.each { |uid| @user_gateways.delete(uid) }
|
|
185
|
+
user_count = user_ids.size
|
|
186
|
+
Rails.logger.info("[CollavreOpenclaw::ConnectionManager] Disconnecting idle connection for gateway #{gateway_url} (#{user_count} users)")
|
|
187
|
+
client&.disconnect!
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "eventmachine"
|
|
2
|
+
|
|
3
|
+
module CollavreOpenclaw
|
|
4
|
+
# Manages a single EventMachine reactor thread shared by all WebSocket connections.
|
|
5
|
+
# Thread-safe: multiple Rails threads can schedule work via .next_tick.
|
|
6
|
+
class EmReactor
|
|
7
|
+
class << self
|
|
8
|
+
def ensure_running!
|
|
9
|
+
@mutex ||= Mutex.new
|
|
10
|
+
@mutex.synchronize do
|
|
11
|
+
return if EM.reactor_running?
|
|
12
|
+
|
|
13
|
+
@thread = Thread.new do
|
|
14
|
+
Thread.current.name = "openclaw-em-reactor"
|
|
15
|
+
EM.run
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Wait until the reactor is actually running
|
|
19
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
|
|
20
|
+
until EM.reactor_running?
|
|
21
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
22
|
+
raise "EventMachine reactor failed to start within 5 seconds"
|
|
23
|
+
end
|
|
24
|
+
sleep 0.01
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stop!
|
|
30
|
+
@mutex&.synchronize do
|
|
31
|
+
return unless EM.reactor_running?
|
|
32
|
+
|
|
33
|
+
# Must stop EM from within the reactor thread
|
|
34
|
+
EM.next_tick { EM.stop }
|
|
35
|
+
@thread&.join(5)
|
|
36
|
+
@thread = nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def running?
|
|
41
|
+
EM.reactor_running?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Schedule a block to run in the EM reactor thread.
|
|
45
|
+
# Safe to call from any thread.
|
|
46
|
+
def next_tick(&block)
|
|
47
|
+
ensure_running!
|
|
48
|
+
EM.next_tick(&block)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|