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.
@@ -4,7 +4,10 @@ require "json"
4
4
  module CollavreOpenclaw
5
5
  class OpenclawAdapter
6
6
  # Adapter for OpenClaw AI Gateway
7
- # Uses OpenAI-compatible /v1/chat/completions endpoint
7
+ #
8
+ # Supports two transport modes:
9
+ # 1. WebSocket (primary) - via faye-websocket + EventMachine
10
+ # 2. HTTP (fallback) - via Faraday POST /v1/chat/completions
8
11
  #
9
12
  # Session mapping:
10
13
  # Collavre Topic → OpenClaw Session (1:1)
@@ -17,37 +20,29 @@ module CollavreOpenclaw
17
20
  @context = context
18
21
  end
19
22
 
20
- def chat(messages, tools: [], &block)
23
+ def chat(messages, &block)
21
24
  unless @user&.gateway_url.present?
22
25
  Rails.logger.error("[CollavreOpenclaw] No Gateway URL configured for user #{@user&.id}")
23
26
  yield "Error: OpenClaw Gateway URL not configured" if block_given?
24
27
  return nil
25
28
  end
26
29
 
27
- response_content = +""
28
-
29
- begin
30
- # Build the request payload (OpenAI format)
31
- payload = build_payload(messages, tools)
32
-
33
- Rails.logger.info("[CollavreOpenclaw] Sending request to #{api_endpoint} (session: #{session_key})")
34
-
35
- # Make streaming request to OpenClaw
36
- stream_response(payload) do |chunk|
37
- response_content << chunk
38
- yield chunk if block_given?
39
- end
30
+ unless @user&.llm_api_key.present?
31
+ Rails.logger.error("[CollavreOpenclaw] No API key configured for user #{@user&.id}")
32
+ yield "Error: OpenClaw API key not configured or decryption failed. " \
33
+ "Please re-enter the API key in AI agent settings." if block_given?
34
+ return nil
35
+ end
40
36
 
41
- response_content.presence
42
- rescue StandardError => e
43
- Rails.logger.error("[CollavreOpenclaw] Chat error: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
44
- error_msg = "OpenClaw Error: #{e.message}"
45
- yield error_msg if block_given?
46
- nil
37
+ # Try WebSocket first, fall back to HTTP
38
+ if websocket_available?
39
+ chat_via_websocket(messages, &block)
40
+ else
41
+ chat_via_http(messages, &block)
47
42
  end
48
43
  end
49
44
 
50
- # Get the callback URL for this user
45
+ # Get the callback URL for this user (kept for backward compatibility)
51
46
  def callback_url
52
47
  return nil unless @user
53
48
 
@@ -70,13 +65,172 @@ module CollavreOpenclaw
70
65
 
71
66
  private
72
67
 
68
+ # ─────────────────────────────────────────────
69
+ # WebSocket transport
70
+ # ─────────────────────────────────────────────
71
+
72
+ def websocket_available?
73
+ # Resolve via the module namespace to trigger Rails autoloading.
74
+ # `defined?` alone does not autoload in dev/test (lazy mode).
75
+ CollavreOpenclaw::ConnectionManager &&
76
+ CollavreOpenclaw::WebsocketClient &&
77
+ true
78
+ rescue NameError
79
+ false
80
+ end
81
+
82
+ def chat_via_websocket(messages, &block)
83
+ response_content = +""
84
+
85
+ begin
86
+ client = ConnectionManager.instance.connection_for(@user)
87
+ message_text = format_message_for_ws(messages)
88
+
89
+ Rails.logger.info("[CollavreOpenclaw] Sending via WebSocket (session: #{session_key})")
90
+
91
+ client.chat_send(
92
+ session_key: session_key,
93
+ message: message_text
94
+ ) do |event|
95
+ case event[:state]
96
+ when "delta"
97
+ if event[:text].present?
98
+ response_content << event[:text]
99
+ yield event[:text] if block_given?
100
+ end
101
+ when "final"
102
+ # If no deltas were streamed, final contains the full text
103
+ if response_content.blank? && event[:text].present?
104
+ response_content << event[:text]
105
+ yield event[:text] if block_given?
106
+ end
107
+ when "error"
108
+ error_msg = event[:text] || "Unknown error"
109
+ yield "OpenClaw Error: #{error_msg}" if block_given?
110
+ when "aborted"
111
+ # User or system aborted
112
+ end
113
+ end
114
+
115
+ response_content.presence
116
+ rescue CollavreOpenclaw::ConnectionError,
117
+ CollavreOpenclaw::TimeoutError => e
118
+ Rails.logger.warn("[CollavreOpenclaw] WebSocket failed, falling back to HTTP: #{e.message}")
119
+ chat_via_http(messages, &block)
120
+ rescue CollavreOpenclaw::ChatError, CollavreOpenclaw::RpcError => e
121
+ Rails.logger.error("[CollavreOpenclaw] WebSocket chat error: #{e.message}")
122
+ error_msg = "OpenClaw Error: #{e.message}"
123
+ yield error_msg if block_given?
124
+ nil
125
+ rescue StandardError => e
126
+ Rails.logger.error("[CollavreOpenclaw] WebSocket unexpected error: #{e.message}\n" \
127
+ "#{e.backtrace.first(5).join("\n")}")
128
+ Rails.logger.info("[CollavreOpenclaw] Falling back to HTTP")
129
+ chat_via_http(messages, &block)
130
+ end
131
+ end
132
+
133
+ # Format messages for WebSocket chat.send (single message string).
134
+ # Gateway manages session history, so we only send the latest user message
135
+ # with optional context prefix on the FIRST message only.
136
+ def format_message_for_ws(messages)
137
+ formatted = Array(messages)
138
+
139
+ # Extract the last user message
140
+ last_user = formatted.reverse.find do |m|
141
+ role = m[:role] || m["role"]
142
+ role.to_s == "user"
143
+ end
144
+
145
+ return "" unless last_user
146
+
147
+ text = extract_message_text(last_user)
148
+
149
+ # Only prepend creative context on the first message in a session.
150
+ # If there are prior assistant replies, the Gateway already has context.
151
+ if first_message_in_session?(formatted)
152
+ context_prefix = build_context_prefix(formatted)
153
+ if context_prefix.present?
154
+ return "#{context_prefix}\n\n#{text}"
155
+ end
156
+ end
157
+
158
+ text.to_s
159
+ end
160
+
161
+ # Returns true when this looks like the first exchange in a session
162
+ # (no prior assistant messages in the conversation history).
163
+ def first_message_in_session?(messages)
164
+ messages.none? do |m|
165
+ role = (m[:role] || m["role"]).to_s
166
+ # "model" is used by some providers (e.g. Gemini) as an alias for "assistant"
167
+ role == "assistant" || role == "model"
168
+ end
169
+ end
170
+
171
+ # Build a context prefix from system/context messages if present.
172
+ # This includes creative tree markdown and other context that the Gateway
173
+ # wouldn't have from its own agent config.
174
+ def build_context_prefix(messages)
175
+ # Find the first "user" message that looks like creative context
176
+ # (typically starts with "Creative:\n")
177
+ context_msg = messages.find do |m|
178
+ role = m[:role] || m["role"]
179
+ text = extract_message_text(m)
180
+ role.to_s == "user" && text&.start_with?("Creative:")
181
+ end
182
+
183
+ return nil unless context_msg
184
+
185
+ extract_message_text(context_msg)
186
+ end
187
+
188
+ def extract_message_text(message)
189
+ parts = message[:parts] || message["parts"]
190
+ if parts
191
+ Array(parts).filter_map { |p| p[:text] || p["text"] }.join("\n")
192
+ else
193
+ message[:text] || message["text"] || message[:content] || message["content"]
194
+ end
195
+ end
196
+
197
+ # ─────────────────────────────────────────────
198
+ # HTTP transport (fallback)
199
+ # ─────────────────────────────────────────────
200
+
201
+ def chat_via_http(messages, &block)
202
+ response_content = +""
203
+
204
+ begin
205
+ payload = build_payload(messages)
206
+
207
+ Rails.logger.info("[CollavreOpenclaw] Sending via HTTP to #{api_endpoint} (session: #{session_key})")
208
+
209
+ stream_response(payload) do |chunk|
210
+ response_content << chunk
211
+ yield chunk if block_given?
212
+ end
213
+
214
+ response_content.presence
215
+ rescue StandardError => e
216
+ Rails.logger.error("[CollavreOpenclaw] HTTP chat error: #{e.message}\n" \
217
+ "#{e.backtrace.first(5).join("\n")}")
218
+ error_msg = "OpenClaw Error: #{e.message}"
219
+ yield error_msg if block_given?
220
+ nil
221
+ end
222
+ end
223
+
73
224
  def api_endpoint
74
- # OpenClaw uses /v1/chat/completions (OpenAI-compatible)
75
225
  uri = URI.parse(@user.gateway_url)
76
226
  uri.path = "/v1/chat/completions"
77
227
  uri.to_s
78
228
  end
79
229
 
230
+ # ─────────────────────────────────────────────
231
+ # Session key
232
+ # ─────────────────────────────────────────────
233
+
80
234
  # Build stable session key based on Topic (not nonce)
81
235
  # Same Topic = Same Session = Shared context between users
82
236
  # Format: agent:<agent_id>:collavre:<user_id>:creative:<id>:topic:<id>
@@ -92,9 +246,11 @@ module CollavreOpenclaw
92
246
  parts.join(":")
93
247
  end
94
248
 
95
- def build_payload(messages, tools)
96
- # Build model string with agent_id derived from user email
97
- # OpenClaw accepts "openclaw:<agentId>" format (e.g., "openclaw:collavre")
249
+ # ─────────────────────────────────────────────
250
+ # HTTP payload building
251
+ # ─────────────────────────────────────────────
252
+
253
+ def build_payload(messages)
98
254
  agent_id = extract_agent_id_from_email
99
255
  model_value = agent_id.present? ? "openclaw:#{agent_id}" : "openclaw"
100
256
 
@@ -104,113 +260,21 @@ module CollavreOpenclaw
104
260
  stream: true
105
261
  }
106
262
 
107
- # Add system prompt as first message if present
108
263
  if @system_prompt.present?
109
264
  payload[:messages].unshift({ role: "system", content: @system_prompt })
110
265
  end
111
266
 
112
- # Add tools if provided (convert to OpenAI function calling format)
113
- if tools.present?
114
- payload[:tools] = format_tools(tools)
115
- end
116
-
117
- # Build user context with callback information
118
267
  payload[:user] = build_user_context
119
-
120
268
  payload
121
269
  end
122
270
 
123
- # Convert tools to OpenAI function calling format
124
- # Accepts either:
125
- # - Array of tool names (strings): ["meta_tool", "search"]
126
- # - Array of OpenAI-format tool objects (already formatted)
127
- def format_tools(tools)
128
- Array(tools).filter_map do |tool|
129
- if tool.is_a?(String)
130
- # Tool name - fetch from MCP and convert to OpenAI format
131
- convert_tool_name_to_openai_format(tool)
132
- elsif tool.is_a?(Hash)
133
- # Already a hash - check if it's OpenAI format or needs conversion
134
- if tool[:type] == "function" || tool["type"] == "function"
135
- # Already OpenAI format
136
- tool
137
- else
138
- # MCP format - convert to OpenAI format
139
- convert_mcp_tool_to_openai_format(tool)
140
- end
141
- end
142
- end.compact
143
- end
144
-
145
- # Convert a tool name to OpenAI function format by fetching from MCP
146
- def convert_tool_name_to_openai_format(tool_name)
147
- return nil unless defined?(::Tools::MetaToolService)
148
-
149
- result = ::Tools::MetaToolService.new.call(action: "get", tool_name: tool_name, query: nil, arguments: nil)
150
- return nil if result[:error] || result[:tool].nil?
151
-
152
- convert_mcp_tool_to_openai_format(result[:tool])
153
- rescue StandardError => e
154
- Rails.logger.warn("[CollavreOpenclaw] Failed to fetch tool #{tool_name}: #{e.message}")
155
- nil
156
- end
157
-
158
- # Convert MCP tool format to OpenAI function format
159
- # MCP format: { name:, description:, params: [...], return_type: }
160
- # OpenAI format: { type: "function", function: { name:, description:, parameters: { type: "object", properties:, required: } } }
161
- def convert_mcp_tool_to_openai_format(mcp_tool)
162
- name = mcp_tool[:name] || mcp_tool["name"]
163
- description = mcp_tool[:description] || mcp_tool["description"]
164
- params = mcp_tool[:params] || mcp_tool["params"] || mcp_tool[:parameters] || mcp_tool["parameters"] || []
165
-
166
- properties = {}
167
- required = []
168
-
169
- Array(params).each do |param|
170
- param_name = (param[:name] || param["name"]).to_s
171
- param_type = param[:type] || param["type"] || "string"
172
- param_desc = param[:description] || param["description"]
173
- param_required = param[:required] || param["required"]
174
-
175
- # Convert Ruby/MCP types to JSON Schema types
176
- json_type = case param_type.to_s.downcase
177
- when "integer", "int" then "integer"
178
- when "number", "float", "decimal" then "number"
179
- when "boolean", "bool" then "boolean"
180
- when "array" then "array"
181
- when "object", "hash" then "object"
182
- else "string"
183
- end
184
-
185
- properties[param_name] = { type: json_type }
186
- properties[param_name][:description] = param_desc if param_desc.present?
187
-
188
- required << param_name if param_required
189
- end
190
-
191
- {
192
- type: "function",
193
- function: {
194
- name: name,
195
- description: description || "",
196
- parameters: {
197
- type: "object",
198
- properties: properties,
199
- required: required
200
- }
201
- }
202
- }
203
- end
204
-
205
271
  def build_user_context
206
272
  context_data = {}
207
273
 
208
- # Extract IDs from context
209
274
  creative_id = extract_id(@context, :creative) || @context[:creative_id]
210
275
  comment_id = extract_id(@context, :comment) || @context[:comment_id]
211
276
  topic_id = @context[:thread_id] || @context[:topic_id]
212
277
 
213
- # Create pending callback with nonce for secure async responses
214
278
  callback = callback_url
215
279
  if callback.present? && creative_id.present?
216
280
  pending = PendingCallback.create_for_request(
@@ -227,10 +291,9 @@ module CollavreOpenclaw
227
291
  context_data[:comment_id] = comment_id if comment_id
228
292
  context_data[:topic_id] = topic_id if topic_id
229
293
 
230
- Rails.logger.info("[CollavreOpenclaw] Created pending callback with nonce: #{pending.nonce[0..8]}...")
294
+ Rails.logger.info("[CollavreOpenclaw] Created pending callback nonce: #{pending.nonce[0..8]}...")
231
295
  end
232
296
 
233
- # Return as JSON string (OpenAI user field format)
234
297
  if context_data.any?
235
298
  "collavre:#{JSON.generate(context_data)}"
236
299
  else
@@ -238,18 +301,11 @@ module CollavreOpenclaw
238
301
  end
239
302
  end
240
303
 
241
- # Format messages with sender attribution for multi-user context
242
304
  def format_messages(messages)
243
305
  Array(messages).map do |msg|
244
306
  role = msg[:role] || msg["role"]
245
- parts = msg[:parts] || msg["parts"]
246
- content = if parts
247
- Array(parts).map { |p| p[:text] || p["text"] }.compact.join("\n")
248
- else
249
- msg[:text] || msg["text"] || msg[:content] || msg["content"]
250
- end
307
+ content = extract_message_text(msg)
251
308
 
252
- # Add sender attribution for user messages (multi-user support)
253
309
  sender_name = msg[:sender_name] || msg["sender_name"]
254
310
  if sender_name.present? && normalize_role(role) == "user"
255
311
  content = "[#{sender_name}]: #{content}"
@@ -267,18 +323,17 @@ module CollavreOpenclaw
267
323
  end
268
324
  end
269
325
 
326
+ # ─────────────────────────────────────────────
327
+ # HTTP streaming
328
+ # ─────────────────────────────────────────────
329
+
270
330
  def build_headers
271
331
  headers = {
272
332
  "Content-Type" => "application/json",
273
333
  "Accept" => "text/event-stream",
274
334
  "x-openclaw-session-key" => session_key
275
335
  }
276
-
277
- # Add Authorization header if API key is configured
278
- if @user&.llm_api_key.present?
279
- headers["Authorization"] = "Bearer #{@user.llm_api_key}"
280
- end
281
-
336
+ headers["Authorization"] = "Bearer #{@user.llm_api_key}" if @user&.llm_api_key.present?
282
337
  headers
283
338
  end
284
339
 
@@ -294,32 +349,38 @@ module CollavreOpenclaw
294
349
  response = connection.post do |req|
295
350
  req.url api_endpoint
296
351
  request_headers.each { |k, v| req.headers[k] = v }
297
-
298
352
  req.body = payload.to_json
299
353
 
300
- req.options.on_data = proc do |chunk, _size, _env|
301
- buffer << chunk
302
- process_sse_buffer(buffer, &block)
354
+ req.options.on_data = proc do |chunk, _size, env|
355
+ if env&.status.nil? || (env.status >= 200 && env.status < 300)
356
+ buffer << chunk
357
+ process_sse_buffer(buffer, &block)
358
+ else
359
+ buffer << chunk
360
+ end
303
361
  end
304
362
  end
305
363
 
306
- # Process any remaining data in buffer
364
+ unless response.status >= 200 && response.status < 300
365
+ error_body = buffer.presence || response.body
366
+ raise parse_error_message(response.status, error_body)
367
+ end
368
+
307
369
  process_sse_buffer(buffer, final: true, &block)
308
370
 
309
- # Handle non-streaming response
310
371
  if response.headers["content-type"]&.include?("application/json")
311
372
  handle_json_response(response.body, &block)
312
373
  end
313
374
 
314
375
  response
315
- rescue Faraday::TimeoutError => e
376
+ rescue Faraday::TimeoutError
316
377
  retries += 1
317
378
  if retries <= max_retries
318
- Rails.logger.warn("[CollavreOpenclaw] Request timed out, retrying (#{retries}/#{max_retries})...")
319
- sleep(1 * retries) # Exponential backoff
379
+ Rails.logger.warn("[CollavreOpenclaw] Timed out, retrying (#{retries}/#{max_retries})...")
380
+ sleep(1 * retries)
320
381
  retry
321
382
  end
322
- raise "OpenClaw request timed out after #{max_retries + 1} attempts (read_timeout: #{CollavreOpenclaw.config.read_timeout}s)"
383
+ raise "OpenClaw request timed out after #{max_retries + 1} attempts"
323
384
  rescue Faraday::ConnectionFailed => e
324
385
  retries += 1
325
386
  if retries <= max_retries
@@ -331,6 +392,25 @@ module CollavreOpenclaw
331
392
  end
332
393
  end
333
394
 
395
+ def parse_error_message(status, body)
396
+ detail = ""
397
+ begin
398
+ json = JSON.parse(body, symbolize_names: true) if body.present?
399
+ detail = json[:error] || json[:message] || json.to_s if json
400
+ rescue JSON::ParserError
401
+ detail = body.to_s.truncate(200)
402
+ end
403
+
404
+ case status
405
+ when 401 then "Authentication failed (HTTP 401). Check your API key. #{detail}"
406
+ when 403 then "Access forbidden (HTTP 403). #{detail}"
407
+ when 404 then "Gateway endpoint not found (HTTP 404). Check your Gateway URL."
408
+ when 429 then "Rate limited (HTTP 429). #{detail}"
409
+ when 500..599 then "Gateway server error (HTTP #{status}). #{detail}"
410
+ else "HTTP #{status}: #{detail}"
411
+ end
412
+ end
413
+
334
414
  def handle_json_response(body, &block)
335
415
  json = JSON.parse(body, symbolize_names: true)
336
416
  content = json.dig(:choices, 0, :message, :content)
@@ -356,23 +436,22 @@ module CollavreOpenclaw
356
436
  line = line.strip
357
437
  next if line.empty? || line.start_with?(":")
358
438
 
359
- if line.start_with?("data:")
360
- data = line.sub(/^data:\s*/, "")
361
- next if data == "[DONE]"
439
+ next unless line.start_with?("data:")
362
440
 
363
- begin
364
- json = JSON.parse(data, symbolize_names: true)
365
- content = extract_content(json)
366
- yield content if content.present?
367
- rescue JSON::ParserError
368
- yield data if data.present?
369
- end
441
+ data = line.sub(/^data:\s*/, "")
442
+ next if data == "[DONE]"
443
+
444
+ begin
445
+ json = JSON.parse(data, symbolize_names: true)
446
+ content = extract_content(json)
447
+ yield content if content.present?
448
+ rescue JSON::ParserError
449
+ yield data if data.present?
370
450
  end
371
451
  end
372
452
  end
373
453
 
374
454
  def extract_content(json)
375
- # OpenAI streaming format
376
455
  json.dig(:choices, 0, :delta, :content) ||
377
456
  json.dig(:choices, 0, :message, :content) ||
378
457
  json[:content] ||
@@ -381,12 +460,16 @@ module CollavreOpenclaw
381
460
 
382
461
  def build_connection
383
462
  Faraday.new do |builder|
384
- builder.options.timeout = CollavreOpenclaw.config.read_timeout # Read timeout (3 min default)
385
- builder.options.open_timeout = CollavreOpenclaw.config.open_timeout # Connection timeout (10s)
463
+ builder.options.timeout = CollavreOpenclaw.config.read_timeout
464
+ builder.options.open_timeout = CollavreOpenclaw.config.open_timeout
386
465
  builder.adapter Faraday.default_adapter
387
466
  end
388
467
  end
389
468
 
469
+ # ─────────────────────────────────────────────
470
+ # Helpers
471
+ # ─────────────────────────────────────────────
472
+
390
473
  def extract_id(context, key)
391
474
  value = context[key] || context[key.to_s]
392
475
  return nil unless value
@@ -395,12 +478,8 @@ module CollavreOpenclaw
395
478
  value[:id] || value["id"]
396
479
  end
397
480
 
398
- # Extract agent_id from user email
399
- # e.g., "ai-agent@collavre.com" -> "ai-agent"
400
481
  def extract_agent_id_from_email
401
482
  return nil unless @user&.email.present?
402
-
403
- # Extract local part (before @) from email
404
483
  @user.email.split("@").first
405
484
  end
406
485
 
@@ -415,7 +494,6 @@ module CollavreOpenclaw
415
494
  result = { host: host }
416
495
  result[:protocol] = options[:protocol] || "https"
417
496
  result[:port] = options[:port] if options[:port].present?
418
-
419
497
  result
420
498
  end
421
499
  end