collavre_openclaw 0.6.4 → 0.6.6
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 +24 -5
- data/app/services/collavre_openclaw/openclaw_adapter.rb +185 -9
- data/app/services/collavre_openclaw/websocket_client.rb +159 -24
- data/config/initializers/ai_client_extension.rb +11 -0
- data/config/initializers/trigger_loop_patterns.rb +12 -0
- data/lib/collavre_openclaw/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a41340e38120813ecf6b2776dc271d533bb3f0e9eb742200a2a844b06fbf0aa4
|
|
4
|
+
data.tar.gz: 17a1c6d6cd3c69c15025a843d93c2eb65b6df91ce6d145a21e9d2159331b7b53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c2e5dd42f34913f03a2d82458b8df854286164fc3089657786cb0b251945c2d59772f379a2d99f51f246c1fdbaf9f6534de6517ff7cafec06acdbf7f01638f6
|
|
7
|
+
data.tar.gz: 8c2a7e4c686854841702115e9d1587fb508566e96591fb0df30673f4b9467356e2268114d92f8e0f9be72fd207e66598f5b3ed4924179873499373dad0607c59
|
|
@@ -15,6 +15,12 @@ module CollavreOpenclaw
|
|
|
15
15
|
# @param messages_input [Hash, Array] Hash { messages:, first_message:, context_changed:, system_prompt: }
|
|
16
16
|
# from SessionContextResolver, or a plain Array from standalone callers (e.g., CompressJob).
|
|
17
17
|
def chat(messages_input, tools: [], &block)
|
|
18
|
+
# Cleared on the way in, as the base #chat clears it — the flag describes
|
|
19
|
+
# the last request, and this branch never reaches `super`. A client is
|
|
20
|
+
# reused across a turn's calls, so a failure left standing would have
|
|
21
|
+
# every later one claiming it delivered nothing.
|
|
22
|
+
@last_handoff_failed = false
|
|
23
|
+
@handed_off = false
|
|
18
24
|
normalized_vendor = vendor.to_s.downcase
|
|
19
25
|
messages_data = normalize_messages_input(messages_input)
|
|
20
26
|
|
|
@@ -27,10 +33,16 @@ module CollavreOpenclaw
|
|
|
27
33
|
# key?(:system_prompt) distinguishes "not provided" (Array input) from "explicitly nil" (incremental session).
|
|
28
34
|
resolved_system_prompt = messages_data.key?(:system_prompt) ? messages_data[:system_prompt] : system_prompt
|
|
29
35
|
user = context&.dig(:user)
|
|
36
|
+
# before_tool_call is the client's lifecycle checkpoint (terminal
|
|
37
|
+
# status + turn deadline). The base client fires it at RubyLLM
|
|
38
|
+
# tool-call boundaries; this path never reaches those, so the adapter
|
|
39
|
+
# gets the same callable to poll mid-stream instead.
|
|
30
40
|
adapter = adapter_class.new(
|
|
31
41
|
user: user,
|
|
32
42
|
system_prompt: resolved_system_prompt,
|
|
33
|
-
context: context
|
|
43
|
+
context: context,
|
|
44
|
+
lifecycle_check: @before_tool_call,
|
|
45
|
+
request_timeout_seconds: @request_timeout_seconds
|
|
34
46
|
)
|
|
35
47
|
|
|
36
48
|
response_content = nil
|
|
@@ -42,10 +54,17 @@ module CollavreOpenclaw
|
|
|
42
54
|
error_message = e.message
|
|
43
55
|
raise
|
|
44
56
|
ensure
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
57
|
+
# The adapter can know that the gateway accepted the request before
|
|
58
|
+
# the caller's streaming block raises (notably CancelledError). Copy
|
|
59
|
+
# both answers while unwinding as well as on a normal return, so
|
|
60
|
+
# AiAgentService can persist the actual handoff rather than infer it
|
|
61
|
+
# from how #chat exited.
|
|
62
|
+
@last_handoff_failed = adapter.last_handoff_failed?
|
|
63
|
+
@handed_off = adapter.handed_off?
|
|
64
|
+
|
|
65
|
+
# Honor no-log mode. Base Collavre::AiClient#chat gates logging behind
|
|
66
|
+
# @log_interactions; this prepended adapter path bypasses super, so it
|
|
67
|
+
# must gate logging too.
|
|
49
68
|
if @log_interactions
|
|
50
69
|
log_interaction(
|
|
51
70
|
messages: messages_data[:messages],
|
|
@@ -3,6 +3,16 @@ require "json"
|
|
|
3
3
|
|
|
4
4
|
module CollavreOpenclaw
|
|
5
5
|
class OpenclawAdapter
|
|
6
|
+
class LifecycleCheckError < StandardError
|
|
7
|
+
attr_reader :original
|
|
8
|
+
|
|
9
|
+
def initialize(original)
|
|
10
|
+
@original = original
|
|
11
|
+
super(original.message)
|
|
12
|
+
set_backtrace(original.backtrace)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
6
16
|
# Pure transport adapter for OpenClaw AI Gateway.
|
|
7
17
|
# Session context filtering (full vs incremental) is handled upstream
|
|
8
18
|
# by SessionContextResolver — this adapter sends exactly what it receives.
|
|
@@ -16,19 +26,62 @@ module CollavreOpenclaw
|
|
|
16
26
|
# Same Topic, multiple users → shared context
|
|
17
27
|
# Different Topics → isolated sessions
|
|
18
28
|
|
|
19
|
-
|
|
29
|
+
# lifecycle_check: callable polled during a chat so the turn can observe
|
|
30
|
+
# an external terminal status or its wall-clock deadline. It has to be
|
|
31
|
+
# injected here because this adapter's tools run remotely on the gateway —
|
|
32
|
+
# there is no local tool-call boundary, and the caller's streaming block
|
|
33
|
+
# fires only on text, which a tool-only run never emits. A raise from it
|
|
34
|
+
# (Collavre::CancelledError or a subclass) unwinds past both transports'
|
|
35
|
+
# fallback rescues.
|
|
36
|
+
def initialize(user:, system_prompt:, context: {}, lifecycle_check: nil, request_timeout_seconds: nil)
|
|
20
37
|
@user = user
|
|
21
38
|
@system_prompt = system_prompt
|
|
22
39
|
@context = context
|
|
40
|
+
@lifecycle_check = wrap_lifecycle_check(lifecycle_check)
|
|
41
|
+
@request_timeout_seconds = request_timeout_seconds
|
|
42
|
+
@last_handoff_failed = false
|
|
43
|
+
@handed_off = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Did the last #chat end without the gateway ever receiving the payload?
|
|
47
|
+
#
|
|
48
|
+
# The same question Collavre::AiClient#last_handoff_failed? answers for its
|
|
49
|
+
# own provider path, and it has to be answered here as well because
|
|
50
|
+
# AiClientExtension#chat routes past that method entirely: every failure
|
|
51
|
+
# below turns into a streamed error plus nil, and nil is also what an
|
|
52
|
+
# ordinary empty answer returns. Orchestration::DeliveryRecord needs the two
|
|
53
|
+
# apart, since a turn that handed the gateway nothing delivered nothing
|
|
54
|
+
# while AiAgentJob still marks it `done`.
|
|
55
|
+
#
|
|
56
|
+
# The proxy is the same one the base client uses: nothing streamed means
|
|
57
|
+
# nothing was handed over. An error *after* deltas is not one of these — the
|
|
58
|
+
# gateway had the payload and answered part of it, so the comments that turn
|
|
59
|
+
# swallowed did reach the agent.
|
|
60
|
+
#
|
|
61
|
+
# The question is asked of the whole #chat, not of a transport: one call can
|
|
62
|
+
# stream over the WebSocket and then fall back to HTTP, so each transport's
|
|
63
|
+
# own response buffer answers only for its own attempt. @handed_off spans
|
|
64
|
+
# both, and is reset once per #chat.
|
|
65
|
+
def last_handoff_failed?
|
|
66
|
+
@last_handoff_failed
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The same fact, asked positively — see AiClient#handed_off?. A turn the
|
|
70
|
+
# user stops mid-answer never reaches the line that sets the flag above.
|
|
71
|
+
def handed_off?
|
|
72
|
+
@handed_off
|
|
23
73
|
end
|
|
24
74
|
|
|
25
75
|
# @param messages_data [Hash] { messages:, first_message:, context_changed: }
|
|
26
76
|
def chat(messages_data, &block)
|
|
77
|
+
@last_handoff_failed = false
|
|
78
|
+
@handed_off = false
|
|
27
79
|
parse_messages_data!(messages_data)
|
|
28
80
|
|
|
29
81
|
unless @user&.gateway_url.present?
|
|
30
82
|
Rails.logger.error("[CollavreOpenclaw] No Gateway URL configured for user #{@user&.id}")
|
|
31
83
|
yield "Error: OpenClaw Gateway URL not configured" if block_given?
|
|
84
|
+
@last_handoff_failed = true
|
|
32
85
|
return nil
|
|
33
86
|
end
|
|
34
87
|
|
|
@@ -36,6 +89,7 @@ module CollavreOpenclaw
|
|
|
36
89
|
Rails.logger.error("[CollavreOpenclaw] No API key configured for user #{@user&.id}")
|
|
37
90
|
yield "Error: OpenClaw API key not configured or decryption failed. " \
|
|
38
91
|
"Please re-enter the API key in AI agent settings." if block_given?
|
|
92
|
+
@last_handoff_failed = true
|
|
39
93
|
return nil
|
|
40
94
|
end
|
|
41
95
|
|
|
@@ -96,6 +150,7 @@ module CollavreOpenclaw
|
|
|
96
150
|
|
|
97
151
|
def chat_via_websocket(&block)
|
|
98
152
|
response_content = +""
|
|
153
|
+
errored = false
|
|
99
154
|
|
|
100
155
|
begin
|
|
101
156
|
client = ConnectionManager.instance.connection_for(@user)
|
|
@@ -107,29 +162,55 @@ module CollavreOpenclaw
|
|
|
107
162
|
session_key: session_key,
|
|
108
163
|
message: payload[:message],
|
|
109
164
|
attachments: payload[:attachments],
|
|
110
|
-
on_run_id: method(:
|
|
165
|
+
on_run_id: method(:handle_run_id),
|
|
166
|
+
lifecycle_check: @lifecycle_check
|
|
111
167
|
) do |event|
|
|
112
168
|
case event[:state]
|
|
113
169
|
when "delta"
|
|
114
170
|
if event[:text].present?
|
|
115
171
|
response_content << event[:text]
|
|
116
|
-
|
|
172
|
+
@handed_off = true
|
|
173
|
+
emit_to_caller(event[:text], &block)
|
|
117
174
|
end
|
|
118
175
|
when "final"
|
|
119
176
|
# If no deltas were streamed, final contains the full text
|
|
120
177
|
if response_content.blank? && event[:text].present?
|
|
121
178
|
response_content << event[:text]
|
|
122
|
-
|
|
179
|
+
@handed_off = true
|
|
180
|
+
emit_to_caller(event[:text], &block)
|
|
123
181
|
end
|
|
124
182
|
when "error"
|
|
125
183
|
error_msg = event[:text] || "Unknown error"
|
|
126
|
-
|
|
184
|
+
errored = true
|
|
185
|
+
emit_to_caller("OpenClaw Error: #{error_msg}", &block)
|
|
127
186
|
when "aborted"
|
|
128
187
|
# User or system aborted
|
|
129
188
|
end
|
|
130
189
|
end
|
|
131
190
|
|
|
191
|
+
# The gateway answered with an error and nothing else: see
|
|
192
|
+
# #last_handoff_failed?. Decided after the stream rather than in the
|
|
193
|
+
# branch above, so a delta that arrives either side of the error still
|
|
194
|
+
# counts as the payload having got through.
|
|
195
|
+
#
|
|
196
|
+
# Inert behind a client that surfaces a run id, since #handle_run_id has
|
|
197
|
+
# already answered by the time any event arrives. Kept for one that does
|
|
198
|
+
# not: an error event is then the only evidence either way, and reading
|
|
199
|
+
# it as a delivery would be the losing mistake.
|
|
200
|
+
@last_handoff_failed = true if errored && !@handed_off
|
|
132
201
|
response_content.presence
|
|
202
|
+
rescue Collavre::CancelledError
|
|
203
|
+
# Raised out of the caller's streaming block when the task hit a
|
|
204
|
+
# terminal status or its turn deadline. The turn is already over —
|
|
205
|
+
# falling back to HTTP would issue a second provider request (and
|
|
206
|
+
# repeat tool side effects) instead of releasing this worker.
|
|
207
|
+
raise
|
|
208
|
+
rescue LifecycleCheckError => e
|
|
209
|
+
# A lifecycle poll is application control flow, not evidence that the
|
|
210
|
+
# WebSocket transport failed. In particular, falling back after the
|
|
211
|
+
# gateway acknowledged chat.send could submit the same tool-bearing
|
|
212
|
+
# payload twice. Restore the callback's original exception unchanged.
|
|
213
|
+
raise e.original
|
|
133
214
|
rescue CollavreOpenclaw::ConnectionError,
|
|
134
215
|
CollavreOpenclaw::TimeoutError => e
|
|
135
216
|
Rails.logger.warn("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
|
|
@@ -138,6 +219,7 @@ module CollavreOpenclaw
|
|
|
138
219
|
Rails.logger.error("[CollavreOpenclaw] WebSocket chat error: #{e.message}")
|
|
139
220
|
error_msg = "OpenClaw Error: #{e.message}"
|
|
140
221
|
yield error_msg if block_given?
|
|
222
|
+
@last_handoff_failed = !@handed_off
|
|
141
223
|
nil
|
|
142
224
|
rescue StandardError => e
|
|
143
225
|
Rails.logger.error("[CollavreOpenclaw] WebSocket unexpected error: #{e.message}\n" \
|
|
@@ -147,6 +229,21 @@ module CollavreOpenclaw
|
|
|
147
229
|
end
|
|
148
230
|
end
|
|
149
231
|
|
|
232
|
+
# The gateway answered chat.send with a run id, which is the handoff: it has
|
|
233
|
+
# the whole payload and the run may already be calling tools. Everything
|
|
234
|
+
# after this — a run that goes quiet until the read timeout, an HTTP
|
|
235
|
+
# fallback that fails before yielding — costs an *answer*, not the delivery,
|
|
236
|
+
# and the comments this turn swallowed are with the agent either way.
|
|
237
|
+
#
|
|
238
|
+
# Reaching here at all is the acknowledgement: WebsocketClient#chat_send
|
|
239
|
+
# calls this straight after send_rpc returns, and send_rpc raises on a read
|
|
240
|
+
# timeout and on an RPC error. Set before persisting, because the handoff
|
|
241
|
+
# happened whether or not we can write the run id down.
|
|
242
|
+
def handle_run_id(run_id)
|
|
243
|
+
@handed_off = true
|
|
244
|
+
persist_run_id_on_comment(run_id)
|
|
245
|
+
end
|
|
246
|
+
|
|
150
247
|
# Claim the run for the solicited reply so the same run's final, re-delivered
|
|
151
248
|
# as "proactive" to other processes, is suppressed. This reply is canonical
|
|
152
249
|
# (it carries the activity log), so it reclaims on a lost race.
|
|
@@ -289,15 +386,27 @@ module CollavreOpenclaw
|
|
|
289
386
|
|
|
290
387
|
stream_response(payload) do |chunk|
|
|
291
388
|
response_content << chunk
|
|
292
|
-
|
|
389
|
+
@handed_off = true
|
|
390
|
+
emit_to_caller(chunk, &block)
|
|
293
391
|
end
|
|
294
392
|
|
|
295
393
|
response_content.presence
|
|
394
|
+
rescue Collavre::CancelledError
|
|
395
|
+
# Same contract as the WebSocket path: a cancelled/deadline-failed
|
|
396
|
+
# turn must release the worker, not be rewritten as a provider error.
|
|
397
|
+
raise
|
|
398
|
+
rescue LifecycleCheckError => e
|
|
399
|
+
# Do not rewrite application/lifecycle failures into provider text.
|
|
400
|
+
raise e.original
|
|
296
401
|
rescue StandardError => e
|
|
297
402
|
Rails.logger.error("[CollavreOpenclaw] HTTP chat error: #{e.message}\n" \
|
|
298
403
|
"#{e.backtrace.first(5).join("\n")}")
|
|
299
404
|
error_msg = "OpenClaw Error: #{e.message}"
|
|
300
405
|
yield error_msg if block_given?
|
|
406
|
+
# Not `response_content.blank?`: this may be the fallback behind a
|
|
407
|
+
# WebSocket that already streamed, and that content is in the caller's
|
|
408
|
+
# hands even though this method's buffer is empty.
|
|
409
|
+
@last_handoff_failed = !@handed_off
|
|
301
410
|
nil
|
|
302
411
|
end
|
|
303
412
|
end
|
|
@@ -443,7 +552,13 @@ module CollavreOpenclaw
|
|
|
443
552
|
req.options.on_data = proc do |chunk, _size, env|
|
|
444
553
|
if env&.status.nil? || (env.status >= 200 && env.status < 300)
|
|
445
554
|
buffer << chunk
|
|
555
|
+
@handed_off = true if env&.response_headers&.[]("content-type")&.include?("application/json")
|
|
446
556
|
process_sse_buffer(buffer, &block)
|
|
557
|
+
# A peer can keep the socket active indefinitely by sending an
|
|
558
|
+
# event without its terminating blank line. Completed events are
|
|
559
|
+
# checked inside the parser after handoff classification; poll
|
|
560
|
+
# here only when an unterminated fragment remains.
|
|
561
|
+
@lifecycle_check&.call if buffer.present?
|
|
447
562
|
else
|
|
448
563
|
buffer << chunk
|
|
449
564
|
end
|
|
@@ -455,9 +570,15 @@ module CollavreOpenclaw
|
|
|
455
570
|
raise parse_error_message(response.status, error_body)
|
|
456
571
|
end
|
|
457
572
|
|
|
573
|
+
json_response = response.headers["content-type"]&.include?("application/json")
|
|
574
|
+
# A completed successful JSON response proves the gateway accepted the
|
|
575
|
+
# payload just as an SSE data event does. Record that before the final
|
|
576
|
+
# lifecycle check, which may abort parsing because the task became
|
|
577
|
+
# terminal while this response was in flight.
|
|
578
|
+
@handed_off = true if json_response
|
|
458
579
|
process_sse_buffer(buffer, final: true, &block)
|
|
459
580
|
|
|
460
|
-
if
|
|
581
|
+
if json_response
|
|
461
582
|
handle_json_response(response.body, &block)
|
|
462
583
|
end
|
|
463
584
|
|
|
@@ -465,16 +586,20 @@ module CollavreOpenclaw
|
|
|
465
586
|
rescue Faraday::TimeoutError
|
|
466
587
|
retries += 1
|
|
467
588
|
if retries <= max_retries
|
|
589
|
+
@lifecycle_check&.call
|
|
468
590
|
Rails.logger.warn("[CollavreOpenclaw] Timed out, retrying (#{retries}/#{max_retries})...")
|
|
469
591
|
sleep(1 * retries)
|
|
592
|
+
@lifecycle_check&.call
|
|
470
593
|
retry
|
|
471
594
|
end
|
|
472
595
|
raise "OpenClaw request timed out after #{max_retries + 1} attempts"
|
|
473
596
|
rescue Faraday::ConnectionFailed => e
|
|
474
597
|
retries += 1
|
|
475
598
|
if retries <= max_retries
|
|
599
|
+
@lifecycle_check&.call
|
|
476
600
|
Rails.logger.warn("[CollavreOpenclaw] Connection failed, retrying (#{retries}/#{max_retries})...")
|
|
477
601
|
sleep(1 * retries)
|
|
602
|
+
@lifecycle_check&.call
|
|
478
603
|
retry
|
|
479
604
|
end
|
|
480
605
|
raise "Failed to connect to OpenClaw after #{max_retries + 1} attempts: #{e.message}"
|
|
@@ -510,16 +635,60 @@ module CollavreOpenclaw
|
|
|
510
635
|
|
|
511
636
|
def process_sse_buffer(buffer, final: false, &block)
|
|
512
637
|
while (idx = buffer.index("\n\n"))
|
|
638
|
+
# The HTTP transport has no event loop to poll from; this parser is
|
|
639
|
+
# its one checkpoint that runs for every received chunk, including
|
|
640
|
+
# tool/comment events that never yield text to the caller's block.
|
|
513
641
|
event_data = buffer.slice!(0, idx + 2)
|
|
642
|
+
# Record delivery first for application data: receiving a successful
|
|
643
|
+
# data event proves the gateway accepted the payload and may already
|
|
644
|
+
# have run tools, even when it contains no caller-visible text. SSE
|
|
645
|
+
# comments are transport keepalives, so they cross the lifecycle check
|
|
646
|
+
# without claiming the agent received anything.
|
|
647
|
+
@handed_off = true if sse_data_event?(event_data)
|
|
648
|
+
@lifecycle_check&.call
|
|
514
649
|
parse_sse_event(event_data, &block)
|
|
515
650
|
end
|
|
516
651
|
|
|
517
652
|
if final && buffer.present?
|
|
653
|
+
@handed_off = true if sse_data_event?(buffer)
|
|
654
|
+
@lifecycle_check&.call
|
|
518
655
|
parse_sse_event(buffer, &block)
|
|
519
656
|
buffer.clear
|
|
520
657
|
end
|
|
521
658
|
end
|
|
522
659
|
|
|
660
|
+
def wrap_lifecycle_check(check)
|
|
661
|
+
return nil unless check
|
|
662
|
+
|
|
663
|
+
lambda do |*args|
|
|
664
|
+
check.call(*args)
|
|
665
|
+
rescue Collavre::CancelledError
|
|
666
|
+
raise
|
|
667
|
+
rescue StandardError => e
|
|
668
|
+
raise LifecycleCheckError.new(e)
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# Exceptions raised by the service's streaming callback are application
|
|
673
|
+
# control flow, not transport failures. Wrap them so the adapter's rescue
|
|
674
|
+
# chain cannot start an HTTP fallback after WebSocket handoff.
|
|
675
|
+
def emit_to_caller(value)
|
|
676
|
+
return unless block_given?
|
|
677
|
+
|
|
678
|
+
yield value
|
|
679
|
+
rescue Collavre::CancelledError
|
|
680
|
+
raise
|
|
681
|
+
rescue StandardError => e
|
|
682
|
+
raise LifecycleCheckError.new(e)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def sse_data_event?(event_str)
|
|
686
|
+
event_str.each_line.any? do |line|
|
|
687
|
+
stripped = line.strip
|
|
688
|
+
stripped.start_with?("data:") && stripped.delete_prefix("data:").strip.present?
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
523
692
|
def parse_sse_event(event_str, &block)
|
|
524
693
|
event_str.each_line do |line|
|
|
525
694
|
line = line.strip
|
|
@@ -549,8 +718,15 @@ module CollavreOpenclaw
|
|
|
549
718
|
|
|
550
719
|
def build_connection
|
|
551
720
|
Faraday.new do |builder|
|
|
552
|
-
|
|
553
|
-
builder.options.
|
|
721
|
+
remaining_deadline = @request_timeout_seconds&.call
|
|
722
|
+
builder.options.timeout = [
|
|
723
|
+
CollavreOpenclaw.config.read_timeout,
|
|
724
|
+
remaining_deadline
|
|
725
|
+
].compact.min
|
|
726
|
+
builder.options.open_timeout = [
|
|
727
|
+
CollavreOpenclaw.config.open_timeout,
|
|
728
|
+
remaining_deadline
|
|
729
|
+
].compact.min
|
|
554
730
|
builder.adapter Faraday.default_adapter
|
|
555
731
|
end
|
|
556
732
|
end
|
|
@@ -158,9 +158,14 @@ module CollavreOpenclaw
|
|
|
158
158
|
# streaming, so callers can persist it as a cross-process idempotency key.
|
|
159
159
|
# @yield [Hash] chat events with :state, :text, :message keys
|
|
160
160
|
# @return [String, nil] final response text
|
|
161
|
-
def chat_send(session_key:, message:, attachments: nil, idempotency_key: nil, on_run_id: nil,
|
|
161
|
+
def chat_send(session_key:, message:, attachments: nil, idempotency_key: nil, on_run_id: nil,
|
|
162
|
+
lifecycle_check: nil, &block)
|
|
162
163
|
ensure_connected!
|
|
163
164
|
touch_activity!
|
|
165
|
+
# Connection establishment can block after the service-level preflight.
|
|
166
|
+
# Recheck before registering queues or scheduling chat.send so a Stop
|
|
167
|
+
# that won during the handshake cannot start remote tool side effects.
|
|
168
|
+
force_lifecycle_check(lifecycle_check) if lifecycle_check
|
|
164
169
|
|
|
165
170
|
idempotency_key ||= SecureRandom.uuid
|
|
166
171
|
actual_run_id = nil
|
|
@@ -186,34 +191,51 @@ module CollavreOpenclaw
|
|
|
186
191
|
}
|
|
187
192
|
rpc_params[:attachments] = attachments if attachments.present?
|
|
188
193
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
194
|
+
acknowledge_response = lambda do |response|
|
|
195
|
+
# The EM thread already registered @pending_runs[actual_run_id] in
|
|
196
|
+
# handle_response. Clean up the idempotency_key entry if a different
|
|
197
|
+
# runId was assigned.
|
|
198
|
+
actual_run_id = response&.dig(:runId) || idempotency_key
|
|
199
|
+
if actual_run_id != idempotency_key
|
|
200
|
+
@mutex.synchronize do
|
|
201
|
+
@pending_runs.delete(idempotency_key)
|
|
202
|
+
# Ensure runId is registered (may already be from handle_response)
|
|
203
|
+
@pending_runs[actual_run_id] ||= run_queue
|
|
204
|
+
end
|
|
200
205
|
end
|
|
201
|
-
end
|
|
202
206
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
# Surface runId before streaming; guard so a faulty callback can't abort the stream.
|
|
208
|
+
if on_run_id
|
|
209
|
+
begin
|
|
210
|
+
on_run_id.call(actual_run_id)
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
Rails.logger.warn("[CollavreOpenclaw::WS] on_run_id callback failed: #{e.message}")
|
|
213
|
+
end
|
|
209
214
|
end
|
|
210
215
|
end
|
|
216
|
+
response = send_rpc(
|
|
217
|
+
"chat.send",
|
|
218
|
+
rpc_params,
|
|
219
|
+
request_id: rpc_request_id,
|
|
220
|
+
lifecycle_check: lifecycle_check,
|
|
221
|
+
&acknowledge_response
|
|
222
|
+
)
|
|
223
|
+
# Compatibility with test/custom send_rpc replacements that return a
|
|
224
|
+
# response but do not invoke the acknowledgement callback.
|
|
225
|
+
acknowledge_response.call(response) unless actual_run_id
|
|
211
226
|
|
|
212
227
|
# Stream events until final/error/aborted
|
|
213
228
|
last_seq = nil
|
|
214
229
|
|
|
215
230
|
loop do
|
|
216
|
-
|
|
231
|
+
# The caller's block fires only on text deltas, and a run doing its
|
|
232
|
+
# tool work on the gateway can emit none for the whole turn. This is
|
|
233
|
+
# the checkpoint that does not depend on text: the wait below polls
|
|
234
|
+
# once before every event and once per idle slice, so both a flood of
|
|
235
|
+
# non-text events and total silence cross it. A raise unwinds through
|
|
236
|
+
# the adapter's CancelledError re-raise instead of holding this worker
|
|
237
|
+
# until read_timeout.
|
|
238
|
+
event = wait_for_chat_event(run_queue, config.read_timeout, lifecycle_check)
|
|
217
239
|
|
|
218
240
|
break if event[:done]
|
|
219
241
|
|
|
@@ -268,6 +290,7 @@ module CollavreOpenclaw
|
|
|
268
290
|
@pending_runs.delete(actual_run_id) if actual_run_id
|
|
269
291
|
# Also clean up idempotency_key if send_rpc failed before we got a runId
|
|
270
292
|
@pending_runs.delete(idempotency_key) if idempotency_key
|
|
293
|
+
@rpc_run_registrations.delete(rpc_request_id) if rpc_request_id
|
|
271
294
|
|
|
272
295
|
# Record completed runs so late-arriving events are suppressed
|
|
273
296
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -599,7 +622,11 @@ module CollavreOpenclaw
|
|
|
599
622
|
# @param request_id [String, nil] Pre-generated request ID. Used by chat_send
|
|
600
623
|
# to correlate the RPC response with the run_queue for EM-thread runId
|
|
601
624
|
# registration (see handle_response). If nil, a random UUID is generated.
|
|
602
|
-
|
|
625
|
+
# @param lifecycle_check [#call, nil] Optional cooperative cancellation
|
|
626
|
+
# check polled while waiting for an RPC response.
|
|
627
|
+
# @yield [Hash] successful response payload before a deferred lifecycle
|
|
628
|
+
# error is re-raised, allowing chat.send to persist its acknowledged runId.
|
|
629
|
+
def send_rpc(method, params, request_id: nil, lifecycle_check: nil, &on_response)
|
|
603
630
|
request_id ||= SecureRandom.uuid
|
|
604
631
|
queue = Queue.new
|
|
605
632
|
|
|
@@ -616,13 +643,25 @@ module CollavreOpenclaw
|
|
|
616
643
|
})
|
|
617
644
|
end
|
|
618
645
|
|
|
619
|
-
result =
|
|
646
|
+
result = if lifecycle_check
|
|
647
|
+
wait_for_rpc_response(queue, config.read_timeout, method, lifecycle_check)
|
|
648
|
+
else
|
|
649
|
+
wait_with_timeout(queue, config.read_timeout, method)
|
|
650
|
+
end
|
|
620
651
|
if result[:error]
|
|
621
652
|
raise RpcError, "#{method} failed: #{result[:error]}"
|
|
622
653
|
end
|
|
623
|
-
|
|
654
|
+
|
|
655
|
+
payload = result[:payload]
|
|
656
|
+
on_response&.call(payload)
|
|
657
|
+
raise result[:deferred_lifecycle_error] if result[:deferred_lifecycle_error]
|
|
658
|
+
|
|
659
|
+
payload
|
|
624
660
|
ensure
|
|
625
|
-
@mutex.synchronize
|
|
661
|
+
@mutex.synchronize do
|
|
662
|
+
@pending_requests.delete(request_id)
|
|
663
|
+
@rpc_run_registrations.delete(request_id)
|
|
664
|
+
end
|
|
626
665
|
end
|
|
627
666
|
|
|
628
667
|
def send_frame(frame)
|
|
@@ -697,6 +736,102 @@ module CollavreOpenclaw
|
|
|
697
736
|
@user.email.split("@").first
|
|
698
737
|
end
|
|
699
738
|
|
|
739
|
+
# Seconds between lifecycle_check invocations while waiting on a silent
|
|
740
|
+
# gateway. Matches AgentLifecycleManager::CANCEL_CHECK_INTERVAL — the
|
|
741
|
+
# check self-throttles at that interval, so polling faster buys nothing.
|
|
742
|
+
LIFECYCLE_POLL_INTERVAL = 1.0
|
|
743
|
+
|
|
744
|
+
# Wait for the next chat event. Without a lifecycle_check this is one
|
|
745
|
+
# blocking pop bounded by read_timeout, exactly as before. With one, the
|
|
746
|
+
# same total bound is kept but the wait is sliced so the check runs
|
|
747
|
+
# between slices — a run that goes quiet doing gateway-side tool work is
|
|
748
|
+
# otherwise unobservable until read_timeout (30 minutes by default).
|
|
749
|
+
def wait_for_chat_event(queue, timeout_seconds, lifecycle_check)
|
|
750
|
+
return wait_with_timeout(queue, timeout_seconds, "chat response") unless lifecycle_check
|
|
751
|
+
|
|
752
|
+
event = wait_with_lifecycle_poll(queue, timeout_seconds, "chat response", lifecycle_check)
|
|
753
|
+
force_lifecycle_check(lifecycle_check) if terminal_chat_event?(event)
|
|
754
|
+
event
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# A chat.send acknowledgement is the provider handoff record: handle_response
|
|
758
|
+
# registers the Gateway runId before queueing it. If cancellation becomes
|
|
759
|
+
# visible at the same time, consume that acknowledgement first so chat_send
|
|
760
|
+
# can surface the runId and clean up its registration. The following chat
|
|
761
|
+
# event wait will still observe cancellation before consuming any run event.
|
|
762
|
+
def wait_for_rpc_response(queue, timeout_seconds, operation, lifecycle_check)
|
|
763
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
|
|
764
|
+
loop do
|
|
765
|
+
result = pop_queue_nonblock(queue)
|
|
766
|
+
if result
|
|
767
|
+
force_lifecycle_check(lifecycle_check) unless result[:ok]
|
|
768
|
+
return result
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
begin
|
|
772
|
+
lifecycle_check.call
|
|
773
|
+
rescue StandardError => error
|
|
774
|
+
# Close the narrow race where the response arrived while the
|
|
775
|
+
# lifecycle check was reading terminal state from the database. This
|
|
776
|
+
# includes wrapped lifecycle infrastructure failures as well as
|
|
777
|
+
# cancellation. Only a successful response proves handoff; an RPC
|
|
778
|
+
# error must not replace the lifecycle failure that just won.
|
|
779
|
+
result = pop_queue_nonblock(queue)
|
|
780
|
+
if result&.dig(:ok)
|
|
781
|
+
result[:deferred_lifecycle_error] = error
|
|
782
|
+
return result
|
|
783
|
+
end
|
|
784
|
+
raise
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
788
|
+
raise TimeoutError, "#{operation} timed out after #{timeout_seconds}s" if remaining <= 0
|
|
789
|
+
|
|
790
|
+
result = queue.pop(timeout: [ LIFECYCLE_POLL_INTERVAL, remaining ].min)
|
|
791
|
+
if result
|
|
792
|
+
force_lifecycle_check(lifecycle_check) unless result[:ok]
|
|
793
|
+
return result
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def wait_with_lifecycle_poll(queue, timeout_seconds, operation, lifecycle_check)
|
|
799
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
|
|
800
|
+
loop do
|
|
801
|
+
lifecycle_check.call
|
|
802
|
+
|
|
803
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
804
|
+
raise TimeoutError, "#{operation} timed out after #{timeout_seconds}s" if remaining <= 0
|
|
805
|
+
|
|
806
|
+
event = queue.pop(timeout: [ LIFECYCLE_POLL_INTERVAL, remaining ].min)
|
|
807
|
+
return event if event
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
def terminal_chat_event?(event)
|
|
812
|
+
event[:done] || %w[final error aborted].include?(event[:state])
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# Production lifecycle checks accept a force flag that bypasses their
|
|
816
|
+
# one-second database polling throttle. Keep zero-argument test/custom
|
|
817
|
+
# callables compatible while still checking at a terminal event boundary.
|
|
818
|
+
def force_lifecycle_check(lifecycle_check)
|
|
819
|
+
parameters = if lifecycle_check.respond_to?(:parameters)
|
|
820
|
+
lifecycle_check.parameters
|
|
821
|
+
else
|
|
822
|
+
lifecycle_check.method(:call).parameters
|
|
823
|
+
end
|
|
824
|
+
accepts_argument = parameters.any? { |type, _name| %i[req opt rest].include?(type) }
|
|
825
|
+
|
|
826
|
+
accepts_argument ? lifecycle_check.call(true) : lifecycle_check.call
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
def pop_queue_nonblock(queue)
|
|
830
|
+
queue.pop(true)
|
|
831
|
+
rescue ThreadError
|
|
832
|
+
nil
|
|
833
|
+
end
|
|
834
|
+
|
|
700
835
|
def wait_with_timeout(queue, timeout_seconds, operation)
|
|
701
836
|
# Use Queue#pop(timeout:) instead of Timeout.timeout to avoid Thread.raise corruption
|
|
702
837
|
result = queue.pop(timeout: timeout_seconds)
|
|
@@ -11,5 +11,16 @@ Rails.application.config.to_prepare do
|
|
|
11
11
|
Collavre::AiClient.register_adapter("openclaw", CollavreOpenclaw::OpenclawAdapter)
|
|
12
12
|
Rails.logger.info("[CollavreOpenclaw] Registered OpenClaw adapter")
|
|
13
13
|
end
|
|
14
|
+
|
|
15
|
+
# OpenClaw uses stateful, incremental sessions and appears in the AI-agent
|
|
16
|
+
# vendor dropdown. Register both into core so core needs no OpenClaw name of
|
|
17
|
+
# its own to decide session support or list the vendor. Both registrations
|
|
18
|
+
# are idempotent, so re-running on reload is safe.
|
|
19
|
+
if Collavre::AiClient.respond_to?(:register_session_vendor)
|
|
20
|
+
Collavre::AiClient.register_session_vendor("openclaw")
|
|
21
|
+
end
|
|
22
|
+
if Collavre::AiClient.respond_to?(:register_vendor_option)
|
|
23
|
+
Collavre::AiClient.register_vendor_option("OpenClaw", "openclaw")
|
|
24
|
+
end
|
|
14
25
|
end
|
|
15
26
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# An OpenClaw gateway error banner marks a task result the agent never actually
|
|
4
|
+
# produced, so the trigger loop must retry it without consuming an iteration.
|
|
5
|
+
# Register the OpenClaw-specific signature into the core trigger-loop infra-error
|
|
6
|
+
# registry so core keeps only vendor-neutral patterns. Runs on boot and every
|
|
7
|
+
# reload; registration is idempotent.
|
|
8
|
+
Rails.application.config.to_prepare do
|
|
9
|
+
if defined?(Collavre::TriggerLoopCheckJob)
|
|
10
|
+
Collavre::TriggerLoopCheckJob.register_infrastructure_error_pattern(/OpenClaw Error/i)
|
|
11
|
+
end
|
|
12
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: collavre_openclaw
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Collavre
|
|
@@ -92,6 +92,7 @@ files:
|
|
|
92
92
|
- config/initializers/ai_client_extension.rb
|
|
93
93
|
- config/initializers/integration_settings.rb
|
|
94
94
|
- config/initializers/session_abort.rb
|
|
95
|
+
- config/initializers/trigger_loop_patterns.rb
|
|
95
96
|
- config/locales/en.yml
|
|
96
97
|
- config/locales/ko.yml
|
|
97
98
|
- config/routes.rb
|