collavre_openclaw 0.5.0 → 0.6.1
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/controllers/collavre_openclaw/health_controller.rb +1 -1
- data/app/services/collavre_openclaw/ai_client_extension.rb +24 -6
- data/app/services/collavre_openclaw/connection_manager.rb +1 -1
- data/app/services/collavre_openclaw/openclaw_adapter.rb +59 -95
- data/app/services/collavre_openclaw/websocket_client.rb +17 -2
- data/lib/collavre_openclaw/configuration.rb +5 -1
- data/lib/collavre_openclaw/engine.rb +1 -1
- data/lib/collavre_openclaw/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '09c2c291bce3fdf441d7c93dacf44883ca47804db6abb38d78b3ea70a412deaf'
|
|
4
|
+
data.tar.gz: 8bd10b6c57adedb5e655b23688088a1be0d8c2e97dca6300ef7470b1a28c8c33
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba347bb7844c93e96b5601f728267df346ba28b78458d77e0ea8a8f83ceb77fc83127ddad0dd164704f8bad95701df1838ac636e8c59aaa356bab07c63e5d58d
|
|
7
|
+
data.tar.gz: 90b4555d3b69f2f0489dff54e6f326b4ee57404989e1ef7068a2b91d8761541ddf42be7154ed6e0a7b8e5f536d6408441b3412997731c3868c603fb3e25a7122
|
|
@@ -12,18 +12,24 @@ module CollavreOpenclaw
|
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# @param messages_input [Hash, Array] Hash { messages:, first_message:, context_changed:, system_prompt: }
|
|
16
|
+
# from SessionContextResolver, or a plain Array from standalone callers (e.g., CompressJob).
|
|
17
|
+
def chat(messages_input, tools: [], &block)
|
|
16
18
|
normalized_vendor = vendor.to_s.downcase
|
|
19
|
+
messages_data = normalize_messages_input(messages_input)
|
|
17
20
|
|
|
18
21
|
# Check if we have a custom adapter for this vendor
|
|
19
22
|
adapter_class = self.class.adapter_registry[normalized_vendor]
|
|
20
23
|
|
|
21
24
|
if adapter_class
|
|
22
25
|
# Use the custom adapter (tools not supported for OpenClaw)
|
|
26
|
+
# Prefer resolved system_prompt from SessionContextResolver over instance default.
|
|
27
|
+
# key?(:system_prompt) distinguishes "not provided" (Array input) from "explicitly nil" (incremental session).
|
|
28
|
+
resolved_system_prompt = messages_data.key?(:system_prompt) ? messages_data[:system_prompt] : system_prompt
|
|
23
29
|
user = context&.dig(:user)
|
|
24
30
|
adapter = adapter_class.new(
|
|
25
31
|
user: user,
|
|
26
|
-
system_prompt:
|
|
32
|
+
system_prompt: resolved_system_prompt,
|
|
27
33
|
context: context
|
|
28
34
|
)
|
|
29
35
|
|
|
@@ -31,13 +37,13 @@ module CollavreOpenclaw
|
|
|
31
37
|
error_message = nil
|
|
32
38
|
|
|
33
39
|
begin
|
|
34
|
-
response_content = adapter.chat(
|
|
40
|
+
response_content = adapter.chat(messages_data, &block)
|
|
35
41
|
rescue StandardError => e
|
|
36
42
|
error_message = e.message
|
|
37
43
|
raise
|
|
38
44
|
ensure
|
|
39
45
|
log_interaction(
|
|
40
|
-
messages:
|
|
46
|
+
messages: messages_data[:messages],
|
|
41
47
|
tools: [],
|
|
42
48
|
response_content: response_content,
|
|
43
49
|
error_message: error_message,
|
|
@@ -49,12 +55,24 @@ module CollavreOpenclaw
|
|
|
49
55
|
return response_content
|
|
50
56
|
end
|
|
51
57
|
|
|
52
|
-
# Fall back to original implementation
|
|
53
|
-
super
|
|
58
|
+
# Fall back to original RubyLLM implementation (expects Array)
|
|
59
|
+
super(messages_data[:messages], tools: tools, &block)
|
|
54
60
|
end
|
|
55
61
|
|
|
56
62
|
private
|
|
57
63
|
|
|
58
64
|
attr_reader :vendor, :system_prompt, :context
|
|
65
|
+
|
|
66
|
+
# Wrap plain Array input (from standalone callers like CompressJob)
|
|
67
|
+
# into the Hash format expected by the adapter.
|
|
68
|
+
def normalize_messages_input(input)
|
|
69
|
+
return input if input.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
messages: Array(input).map { |m| m.merge(kind: :trigger) },
|
|
73
|
+
first_message: true,
|
|
74
|
+
context_changed: false
|
|
75
|
+
}
|
|
76
|
+
end
|
|
59
77
|
end
|
|
60
78
|
end
|
|
@@ -205,7 +205,7 @@ module CollavreOpenclaw
|
|
|
205
205
|
next unless @idle_check_counter >= 60 # Run check every ~60 seconds
|
|
206
206
|
@idle_check_counter = 0
|
|
207
207
|
check_idle_connections!
|
|
208
|
-
rescue => e
|
|
208
|
+
rescue StandardError => e
|
|
209
209
|
Rails.logger.error("[CollavreOpenclaw::ConnectionManager] Idle checker error: #{e.message}")
|
|
210
210
|
end
|
|
211
211
|
end
|
|
@@ -3,9 +3,11 @@ require "json"
|
|
|
3
3
|
|
|
4
4
|
module CollavreOpenclaw
|
|
5
5
|
class OpenclawAdapter
|
|
6
|
-
#
|
|
6
|
+
# Pure transport adapter for OpenClaw AI Gateway.
|
|
7
|
+
# Session context filtering (full vs incremental) is handled upstream
|
|
8
|
+
# by SessionContextResolver — this adapter sends exactly what it receives.
|
|
7
9
|
#
|
|
8
|
-
#
|
|
10
|
+
# Transport modes:
|
|
9
11
|
# 1. WebSocket (primary) - via faye-websocket + EventMachine
|
|
10
12
|
# 2. HTTP (fallback) - via Faraday POST /v1/chat/completions
|
|
11
13
|
#
|
|
@@ -20,7 +22,10 @@ module CollavreOpenclaw
|
|
|
20
22
|
@context = context
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
# @param messages_data [Hash] { messages:, first_message:, context_changed: }
|
|
26
|
+
def chat(messages_data, &block)
|
|
27
|
+
parse_messages_data!(messages_data)
|
|
28
|
+
|
|
24
29
|
unless @user&.gateway_url.present?
|
|
25
30
|
Rails.logger.error("[CollavreOpenclaw] No Gateway URL configured for user #{@user&.id}")
|
|
26
31
|
yield "Error: OpenClaw Gateway URL not configured" if block_given?
|
|
@@ -38,11 +43,11 @@ module CollavreOpenclaw
|
|
|
38
43
|
# Set OPENCLAW_TRANSPORT=http to force HTTP-only mode
|
|
39
44
|
if CollavreOpenclaw.config.transport == "http"
|
|
40
45
|
Rails.logger.info("[CollavreOpenclaw::WS] TRANSPORT mode=http_forced")
|
|
41
|
-
chat_via_http(
|
|
46
|
+
chat_via_http(&block)
|
|
42
47
|
elsif websocket_available?
|
|
43
|
-
chat_via_websocket(
|
|
48
|
+
chat_via_websocket(&block)
|
|
44
49
|
else
|
|
45
|
-
chat_via_http(
|
|
50
|
+
chat_via_http(&block)
|
|
46
51
|
end
|
|
47
52
|
end
|
|
48
53
|
|
|
@@ -69,6 +74,12 @@ module CollavreOpenclaw
|
|
|
69
74
|
|
|
70
75
|
private
|
|
71
76
|
|
|
77
|
+
def parse_messages_data!(data)
|
|
78
|
+
@all_messages = data[:messages] || []
|
|
79
|
+
@first_message = data[:first_message]
|
|
80
|
+
@context_changed = data[:context_changed]
|
|
81
|
+
end
|
|
82
|
+
|
|
72
83
|
# ─────────────────────────────────────────────
|
|
73
84
|
# WebSocket transport
|
|
74
85
|
# ─────────────────────────────────────────────
|
|
@@ -83,14 +94,14 @@ module CollavreOpenclaw
|
|
|
83
94
|
false
|
|
84
95
|
end
|
|
85
96
|
|
|
86
|
-
def chat_via_websocket(
|
|
97
|
+
def chat_via_websocket(&block)
|
|
87
98
|
response_content = +""
|
|
88
99
|
|
|
89
100
|
begin
|
|
90
101
|
client = ConnectionManager.instance.connection_for(@user)
|
|
91
|
-
payload = build_ws_chat_payload
|
|
102
|
+
payload = build_ws_chat_payload
|
|
92
103
|
|
|
93
|
-
Rails.logger.info("[CollavreOpenclaw] Sending via WebSocket (session: #{session_key})")
|
|
104
|
+
Rails.logger.info("[CollavreOpenclaw] Sending via WebSocket (session: #{session_key}, first: #{@first_message}, changed: #{@context_changed})")
|
|
94
105
|
|
|
95
106
|
client.chat_send(
|
|
96
107
|
session_key: session_key,
|
|
@@ -121,7 +132,7 @@ module CollavreOpenclaw
|
|
|
121
132
|
rescue CollavreOpenclaw::ConnectionError,
|
|
122
133
|
CollavreOpenclaw::TimeoutError => e
|
|
123
134
|
Rails.logger.warn("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
|
|
124
|
-
chat_via_http(
|
|
135
|
+
chat_via_http(&block)
|
|
125
136
|
rescue CollavreOpenclaw::ChatError, CollavreOpenclaw::RpcError => e
|
|
126
137
|
Rails.logger.error("[CollavreOpenclaw] WebSocket chat error: #{e.message}")
|
|
127
138
|
error_msg = "OpenClaw Error: #{e.message}"
|
|
@@ -131,70 +142,26 @@ module CollavreOpenclaw
|
|
|
131
142
|
Rails.logger.error("[CollavreOpenclaw] WebSocket unexpected error: #{e.message}\n" \
|
|
132
143
|
"#{e.backtrace.first(5).join("\n")}")
|
|
133
144
|
Rails.logger.info("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
|
|
134
|
-
chat_via_http(
|
|
145
|
+
chat_via_http(&block)
|
|
135
146
|
end
|
|
136
147
|
end
|
|
137
148
|
|
|
138
|
-
|
|
139
|
-
#
|
|
140
|
-
# Includes the same full text context as HTTP mode plus optional base64
|
|
141
|
-
# image attachments supported by the Gateway's chat.send.attachments field.
|
|
142
|
-
def build_ws_chat_payload(messages)
|
|
149
|
+
def build_ws_chat_payload
|
|
143
150
|
{
|
|
144
|
-
message: format_message_for_ws
|
|
145
|
-
attachments: extract_ws_attachments(
|
|
151
|
+
message: format_message_for_ws,
|
|
152
|
+
attachments: extract_ws_attachments(@all_messages).presence
|
|
146
153
|
}
|
|
147
154
|
end
|
|
148
155
|
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
# The Gateway's WS session may be new (no prior history), so we cannot
|
|
153
|
-
# assume context was sent before. This is consistent with how the HTTP
|
|
154
|
-
# Chat Completions API works (full history on every request).
|
|
155
|
-
#
|
|
156
|
-
# Message structure sent:
|
|
157
|
-
# [system prompt]
|
|
158
|
-
# [creative context messages]
|
|
159
|
-
# [context creative messages]
|
|
160
|
-
# [chat history]
|
|
161
|
-
# [latest user message]
|
|
162
|
-
def format_message_for_ws(messages)
|
|
163
|
-
formatted = Array(messages)
|
|
164
|
-
return "" if formatted.empty?
|
|
165
|
-
|
|
156
|
+
# SessionContextResolver already decided what to include.
|
|
157
|
+
# We just format and send everything we received.
|
|
158
|
+
def format_message_for_ws
|
|
166
159
|
parts = []
|
|
167
|
-
|
|
168
|
-
# 1. System prompt (same as HTTP mode's build_payload)
|
|
169
160
|
parts << @system_prompt if @system_prompt.present?
|
|
170
161
|
|
|
171
|
-
|
|
172
|
-
formatted.each do |m|
|
|
173
|
-
role = m[:role] || m["role"]
|
|
174
|
-
next unless role.to_s == "user"
|
|
175
|
-
|
|
162
|
+
@all_messages.each do |m|
|
|
176
163
|
text = extract_message_text(m)
|
|
177
|
-
|
|
178
|
-
next unless text.match?(/\A(Creative|Context Creative|Referenced Creative)\s*\(/)
|
|
179
|
-
|
|
180
|
-
parts << text
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# 3. Chat history (prior user/assistant exchanges)
|
|
184
|
-
formatted.each do |m|
|
|
185
|
-
role = (m[:role] || m["role"]).to_s
|
|
186
|
-
text = extract_message_text(m)
|
|
187
|
-
next unless text.present?
|
|
188
|
-
|
|
189
|
-
# Skip context messages (already included above)
|
|
190
|
-
next if text.match?(/\A(Creative|Context Creative|Referenced Creative)\s*\(/)
|
|
191
|
-
|
|
192
|
-
case role
|
|
193
|
-
when "user"
|
|
194
|
-
parts << text
|
|
195
|
-
when "assistant", "model"
|
|
196
|
-
parts << "[Assistant]: #{text}"
|
|
197
|
-
end
|
|
164
|
+
parts << text if text.present?
|
|
198
165
|
end
|
|
199
166
|
|
|
200
167
|
parts.join("\n\n")
|
|
@@ -297,13 +264,13 @@ module CollavreOpenclaw
|
|
|
297
264
|
# HTTP transport (fallback)
|
|
298
265
|
# ─────────────────────────────────────────────
|
|
299
266
|
|
|
300
|
-
def chat_via_http(
|
|
267
|
+
def chat_via_http(&block)
|
|
301
268
|
response_content = +""
|
|
302
269
|
|
|
303
270
|
begin
|
|
304
|
-
payload = build_payload
|
|
271
|
+
payload = build_payload
|
|
305
272
|
|
|
306
|
-
Rails.logger.info("[CollavreOpenclaw] Sending via HTTP to #{api_endpoint} (session: #{session_key})")
|
|
273
|
+
Rails.logger.info("[CollavreOpenclaw] Sending via HTTP to #{api_endpoint} (session: #{session_key}, first: #{@first_message}, changed: #{@context_changed})")
|
|
307
274
|
|
|
308
275
|
stream_response(payload) do |chunk|
|
|
309
276
|
response_content << chunk
|
|
@@ -349,22 +316,21 @@ module CollavreOpenclaw
|
|
|
349
316
|
# HTTP payload building
|
|
350
317
|
# ─────────────────────────────────────────────
|
|
351
318
|
|
|
352
|
-
|
|
319
|
+
# SessionContextResolver already decided what to include.
|
|
320
|
+
def build_payload
|
|
353
321
|
agent_id = extract_agent_id_from_email
|
|
354
322
|
model_value = agent_id.present? ? "openclaw:#{agent_id}" : "openclaw"
|
|
355
323
|
|
|
356
|
-
|
|
324
|
+
formatted = []
|
|
325
|
+
formatted << { role: "system", content: @system_prompt } if @system_prompt.present?
|
|
326
|
+
@all_messages.each { |m| formatted << format_single_message(m) }
|
|
327
|
+
|
|
328
|
+
{
|
|
357
329
|
model: model_value,
|
|
358
|
-
messages:
|
|
359
|
-
stream: true
|
|
330
|
+
messages: formatted,
|
|
331
|
+
stream: true,
|
|
332
|
+
user: build_user_context
|
|
360
333
|
}
|
|
361
|
-
|
|
362
|
-
if @system_prompt.present?
|
|
363
|
-
payload[:messages].unshift({ role: "system", content: @system_prompt })
|
|
364
|
-
end
|
|
365
|
-
|
|
366
|
-
payload[:user] = build_user_context
|
|
367
|
-
payload
|
|
368
334
|
end
|
|
369
335
|
|
|
370
336
|
def build_user_context
|
|
@@ -400,28 +366,26 @@ module CollavreOpenclaw
|
|
|
400
366
|
end
|
|
401
367
|
end
|
|
402
368
|
|
|
403
|
-
def
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
text = extract_message_text(msg)
|
|
369
|
+
def format_single_message(msg)
|
|
370
|
+
role = msg[:role] || msg["role"]
|
|
371
|
+
text = extract_message_text(msg)
|
|
407
372
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
373
|
+
sender_name = msg[:sender_name] || msg["sender_name"]
|
|
374
|
+
if sender_name.present? && normalize_role(role) == "user"
|
|
375
|
+
text = "[#{sender_name}]: #{text}"
|
|
376
|
+
end
|
|
412
377
|
|
|
413
|
-
|
|
378
|
+
image_sources = extract_image_sources(msg)
|
|
414
379
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
end
|
|
421
|
-
{ role: normalize_role(role), content: content_parts }
|
|
422
|
-
else
|
|
423
|
-
{ role: normalize_role(role), content: text.to_s }
|
|
380
|
+
if image_sources.any?
|
|
381
|
+
content_parts = [ { type: "text", text: text.to_s } ]
|
|
382
|
+
image_sources.each do |source|
|
|
383
|
+
image_data = encode_image_source(source)
|
|
384
|
+
content_parts << image_data if image_data
|
|
424
385
|
end
|
|
386
|
+
{ role: normalize_role(role), content: content_parts }
|
|
387
|
+
else
|
|
388
|
+
{ role: normalize_role(role), content: text.to_s }
|
|
425
389
|
end
|
|
426
390
|
end
|
|
427
391
|
|
|
@@ -94,7 +94,7 @@ module CollavreOpenclaw
|
|
|
94
94
|
EmReactor.next_tick do
|
|
95
95
|
begin
|
|
96
96
|
do_connect!(queue)
|
|
97
|
-
rescue => e
|
|
97
|
+
rescue StandardError => e
|
|
98
98
|
queue.push({ error: e.message })
|
|
99
99
|
end
|
|
100
100
|
end
|
|
@@ -265,6 +265,21 @@ module CollavreOpenclaw
|
|
|
265
265
|
end
|
|
266
266
|
end
|
|
267
267
|
|
|
268
|
+
# Inject an assistant message into a session transcript.
|
|
269
|
+
# Useful for pre-populating context without triggering an agent run.
|
|
270
|
+
#
|
|
271
|
+
# @param session_key [String]
|
|
272
|
+
# @param message [String] content to inject
|
|
273
|
+
# @param label [String, nil] optional label for the injected message
|
|
274
|
+
def chat_inject(session_key:, message:, label: nil)
|
|
275
|
+
ensure_connected!
|
|
276
|
+
touch_activity!
|
|
277
|
+
|
|
278
|
+
params = { sessionKey: session_key, message: message }
|
|
279
|
+
params[:label] = label if label
|
|
280
|
+
send_rpc("chat.inject", params)
|
|
281
|
+
end
|
|
282
|
+
|
|
268
283
|
# Fetch chat history for a session
|
|
269
284
|
def chat_history(session_key:, limit: nil)
|
|
270
285
|
ensure_connected!
|
|
@@ -644,7 +659,7 @@ module CollavreOpenclaw
|
|
|
644
659
|
schedule_reconnect!
|
|
645
660
|
end
|
|
646
661
|
end
|
|
647
|
-
rescue => e
|
|
662
|
+
rescue StandardError => e
|
|
648
663
|
Rails.logger.error("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} state=fail reason=#{e.message}")
|
|
649
664
|
schedule_reconnect!
|
|
650
665
|
end
|
|
@@ -27,7 +27,11 @@ module CollavreOpenclaw
|
|
|
27
27
|
|
|
28
28
|
def initialize
|
|
29
29
|
@open_timeout = ENV.fetch("OPENCLAW_OPEN_TIMEOUT", 10).to_i
|
|
30
|
-
@read_timeout =
|
|
30
|
+
@read_timeout = begin
|
|
31
|
+
Collavre::SystemSetting.llm_request_timeout_seconds
|
|
32
|
+
rescue StandardError
|
|
33
|
+
1800
|
|
34
|
+
end
|
|
31
35
|
@max_retries = ENV.fetch("OPENCLAW_MAX_RETRIES", 2).to_i
|
|
32
36
|
@ws_idle_timeout = ENV.fetch("OPENCLAW_WS_IDLE_TIMEOUT", 1800).to_i # 30 minutes
|
|
33
37
|
@ws_reconnect_max = ENV.fetch("OPENCLAW_WS_RECONNECT_MAX", 10).to_i
|