solid_loop 0.0.4 → 0.0.5
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/CHANGELOG.md +445 -0
- data/README.md +305 -4
- data/Rakefile +5 -4
- data/app/controllers/solid_loop/application_controller.rb +6 -0
- data/app/controllers/solid_loop/dashboard_controller.rb +167 -22
- data/app/controllers/solid_loop/events_controller.rb +9 -1
- data/app/controllers/solid_loop/mcp_sessions_controller.rb +12 -0
- data/app/controllers/solid_loop/messages_controller.rb +11 -1
- data/app/controllers/solid_loop/tool_calls_controller.rb +32 -0
- data/app/helpers/solid_loop/application_helper.rb +4 -1
- data/app/helpers/solid_loop/metrics_helper.rb +232 -0
- data/app/jobs/solid_loop/janitor_job.rb +24 -0
- data/app/jobs/solid_loop/llm_completion_job.rb +2 -2
- data/app/models/solid_loop/base.rb +89 -6
- data/app/models/solid_loop/loop.rb +22 -0
- data/app/models/solid_loop/message.rb +33 -7
- data/app/models/solid_loop/tool_call.rb +7 -1
- data/app/services/solid_loop/adapters/native.rb +156 -23
- data/app/services/solid_loop/dialects/anthropic.rb +43 -10
- data/app/services/solid_loop/dialects/gemini.rb +55 -15
- data/app/services/solid_loop/dialects/open_ai.rb +17 -5
- data/app/services/solid_loop/dialects/reasoning_packer.rb +72 -7
- data/app/services/solid_loop/llm_usage_parser/llama.rb +18 -3
- data/app/services/solid_loop/mcp_session_initializer.rb +1 -0
- data/app/services/solid_loop/middlewares/agent_initialization.rb +1 -1
- data/app/services/solid_loop/middlewares/error_handling.rb +5 -1
- data/app/services/solid_loop/middlewares/event_logging.rb +3 -3
- data/app/services/solid_loop/middlewares/message_building.rb +38 -4
- data/app/services/solid_loop/middlewares/response_parsing.rb +19 -3
- data/app/views/layouts/solid_loop/admin.html.erb +134 -23
- data/app/views/solid_loop/dashboard/index.html.erb +330 -41
- data/app/views/solid_loop/events/index.html.erb +17 -1
- data/app/views/solid_loop/loops/index.html.erb +40 -3
- data/app/views/solid_loop/loops/show.html.erb +1 -1
- data/app/views/solid_loop/mcp_sessions/index.html.erb +40 -2
- data/app/views/solid_loop/messages/_message.html.erb +26 -35
- data/app/views/solid_loop/messages/index.html.erb +16 -2
- data/app/views/solid_loop/tool_calls/index.html.erb +18 -3
- data/db/migrate/20260819000100_solid_loop_add_retention_indexes.rb +22 -0
- data/docs/contributing/coverage.md +8 -8
- data/docs/decisions/mcp-server.md +4 -2
- data/docs/decisions/reasoning_persistence.md +120 -4
- data/docs/guides/dialects.md +1 -1
- data/docs/guides/mcp_transports.md +72 -0
- data/docs/validation.md +85 -0
- data/lib/solid_loop/configuration.rb +93 -0
- data/lib/solid_loop/engine.rb +8 -0
- data/lib/solid_loop/janitor.rb +110 -0
- data/lib/solid_loop/mcp/toolset.rb +119 -31
- data/lib/solid_loop/pipeline/builder.rb +31 -10
- data/lib/solid_loop/redaction.rb +26 -0
- data/lib/solid_loop/version.rb +1 -1
- data/lib/solid_loop.rb +47 -0
- metadata +7 -2
- data/lib/tasks/coverage.rake +0 -206
|
@@ -3,12 +3,14 @@ module SolidLoop
|
|
|
3
3
|
class Native
|
|
4
4
|
attr_writer :on_chunk_proc
|
|
5
5
|
|
|
6
|
-
def initialize(base_url:, api_token:, payload:, streaming: false, reasoning_strategies:
|
|
6
|
+
def initialize(base_url:, api_token:, payload:, streaming: false, reasoning_strategies: nil, dialect: nil, model_name: nil, fallback_messages_text: "", read_timeout: nil, cancellation_check: nil)
|
|
7
7
|
@base_url = base_url
|
|
8
8
|
@api_token = api_token
|
|
9
9
|
@payload = payload
|
|
10
10
|
@streaming = streaming
|
|
11
|
-
|
|
11
|
+
# nil is meaningful and must survive: it means "let the dialect choose",
|
|
12
|
+
# which is not the same as [] ("send no reasoning back").
|
|
13
|
+
@reasoning_strategies = reasoning_strategies.nil? ? nil : Array(reasoning_strategies)
|
|
12
14
|
@model_name = model_name
|
|
13
15
|
@fallback_messages_text = fallback_messages_text
|
|
14
16
|
# Single coherent read-timeout source: when no explicit
|
|
@@ -31,13 +33,14 @@ module SolidLoop
|
|
|
31
33
|
@last_ui_update_at = Time.current
|
|
32
34
|
@first_token_at = nil
|
|
33
35
|
@content_started_at = nil
|
|
36
|
+
# Latched true the moment a chunk is handed to the caller; forbids retry
|
|
37
|
+
# from then on (see emit_chunk_event / retry_allowed?).
|
|
38
|
+
@emitted_to_caller = false
|
|
34
39
|
@aggregator = SolidLoop::SseStreamAggregator.new
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
def call
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@dialect.apply_reasoning_strategies!(@payload[:messages], @reasoning_strategies)
|
|
43
|
+
reasoning_carried = @dialect.apply_reasoning_strategies!(@payload[:messages], @reasoning_strategies)
|
|
41
44
|
final_payload = @dialect.respond_to?(:render_payload) ? @dialect.render_payload(@payload) : @payload
|
|
42
45
|
url = build_url
|
|
43
46
|
|
|
@@ -54,26 +57,39 @@ module SolidLoop
|
|
|
54
57
|
f.adapter Faraday.default_adapter
|
|
55
58
|
end
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
attempt = 0
|
|
61
|
+
loop do
|
|
62
|
+
attempt += 1
|
|
63
|
+
# Per-attempt reset, so ttft/duration and the aggregated stream describe
|
|
64
|
+
# the attempt that actually produced the answer, not the discarded ones.
|
|
65
|
+
# Retries themselves stay visible in the wire log.
|
|
66
|
+
reset_attempt_state!
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
transient = nil
|
|
69
|
+
begin
|
|
70
|
+
post_once(conn, headers, final_payload)
|
|
71
|
+
transient = retry_after_seconds(@response) if retryable_response?(@response)
|
|
72
|
+
rescue Faraday::ConnectionFailed => e
|
|
73
|
+
# The connection never established, so no bytes reached the caller and
|
|
74
|
+
# the request provably did not execute — safe to repeat. A
|
|
75
|
+
# Faraday::TimeoutError is deliberately NOT caught: we already waited
|
|
76
|
+
# the full read_timeout, the request may well have been served, and
|
|
77
|
+
# repeating it would multiply the turn's wall clock.
|
|
78
|
+
raise e unless retry_allowed?(attempt)
|
|
79
|
+
|
|
80
|
+
@log_buffer.puts("retry: attempt #{attempt} failed with #{e.class}: #{e.message}")
|
|
81
|
+
wait_before_retry(backoff_delay(attempt))
|
|
82
|
+
next
|
|
74
83
|
end
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
|
|
85
|
+
break unless transient
|
|
86
|
+
break unless retry_allowed?(attempt)
|
|
87
|
+
|
|
88
|
+
@log_buffer.puts(
|
|
89
|
+
"retry: attempt #{attempt} got HTTP #{@response.status}" \
|
|
90
|
+
"#{transient == :backoff ? '' : " (Retry-After: #{transient}s)"}"
|
|
91
|
+
)
|
|
92
|
+
wait_before_retry(transient == :backoff ? backoff_delay(attempt) : transient)
|
|
77
93
|
end
|
|
78
94
|
|
|
79
95
|
duration = (Time.current - @request_started_at).to_f
|
|
@@ -95,6 +111,8 @@ module SolidLoop
|
|
|
95
111
|
fallback_messages_text: @fallback_messages_text
|
|
96
112
|
)
|
|
97
113
|
|
|
114
|
+
annotate_reasoning_carry!(normalized_data, reasoning_carried)
|
|
115
|
+
|
|
98
116
|
{
|
|
99
117
|
response: @response,
|
|
100
118
|
normalized_data: normalized_data,
|
|
@@ -117,6 +135,24 @@ module SolidLoop
|
|
|
117
135
|
|
|
118
136
|
private
|
|
119
137
|
|
|
138
|
+
# Records what this request's prompt spent on reasoning from earlier turns,
|
|
139
|
+
# so the cost is a number on the message rather than something a host has
|
|
140
|
+
# to reconstruct from message lengths after the fact. `inline_chars` is the
|
|
141
|
+
# part welded into `content` — the part every provider bills.
|
|
142
|
+
def annotate_reasoning_carry!(normalized_data, carried)
|
|
143
|
+
return unless normalized_data.is_a?(Hash) && carried.is_a?(Hash)
|
|
144
|
+
return if carried[:chars].to_i.zero?
|
|
145
|
+
|
|
146
|
+
normalized_data[:metadata] = (normalized_data[:metadata] || {}).merge(
|
|
147
|
+
"reasoning_carried" => {
|
|
148
|
+
"messages" => carried[:messages].to_i,
|
|
149
|
+
"chars" => carried[:chars].to_i,
|
|
150
|
+
"inline_chars" => carried[:inline_chars].to_i,
|
|
151
|
+
"tokens_est" => carried[:chars].to_i / 4
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
120
156
|
def build_watchdog(target_thread, stop_flag:)
|
|
121
157
|
return nil unless @cancellation_check
|
|
122
158
|
|
|
@@ -163,8 +199,105 @@ module SolidLoop
|
|
|
163
199
|
)
|
|
164
200
|
|
|
165
201
|
@on_chunk_proc&.call(data[:content], data[:reasoning], data[:tool_calls], metrics)
|
|
202
|
+
# The retry gate: once a chunk has reached the caller, the assistant
|
|
203
|
+
# message shell already holds partial content and re-sending would
|
|
204
|
+
# concatenate two generations. Set at the exact call site, not merely on
|
|
205
|
+
# "bytes arrived" — a streamed ERROR body arrives as bytes but never
|
|
206
|
+
# reaches the caller, and that case must stay retryable.
|
|
207
|
+
@emitted_to_caller = true
|
|
166
208
|
@last_ui_update_at = Time.current
|
|
167
209
|
end
|
|
210
|
+
|
|
211
|
+
def post_once(conn, headers, final_payload)
|
|
212
|
+
caller_thread = Thread.current
|
|
213
|
+
stop_watchdog = false
|
|
214
|
+
_watchdog = build_watchdog(caller_thread, stop_flag: -> { stop_watchdog })
|
|
215
|
+
|
|
216
|
+
@response = conn.post do |req|
|
|
217
|
+
req.headers = headers
|
|
218
|
+
req.body = final_payload.to_json
|
|
219
|
+
|
|
220
|
+
if @streaming
|
|
221
|
+
req.options.on_data = proc do |chunk, _overall_received_bytes|
|
|
222
|
+
@debug_buffer.write(chunk)
|
|
223
|
+
@first_token_at ||= Time.current
|
|
224
|
+
data = parse_stream_chunk(chunk)
|
|
225
|
+
emit_chunk_event(data)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
ensure
|
|
230
|
+
stop_watchdog = true
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Everything an attempt accumulates, cleared so a retry starts from zero.
|
|
234
|
+
# @emitted_to_caller is NOT reset — it is the irreversible "the shell has
|
|
235
|
+
# been written to" latch that forbids any further retry.
|
|
236
|
+
def reset_attempt_state!
|
|
237
|
+
@request_started_at = Time.current
|
|
238
|
+
@first_token_at = nil
|
|
239
|
+
@content_started_at = nil
|
|
240
|
+
@last_ui_update_at = Time.current
|
|
241
|
+
@aggregator = SolidLoop::SseStreamAggregator.new
|
|
242
|
+
@debug_buffer = StringIO.new
|
|
243
|
+
@response = nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def retry_allowed?(attempt)
|
|
247
|
+
return false if @emitted_to_caller
|
|
248
|
+
|
|
249
|
+
attempt <= SolidLoop.config.llm_retries
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def retryable_response?(response)
|
|
253
|
+
response && SolidLoop.config.llm_retry_statuses.include?(response.status)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# The provider's own instruction, capped — or :backoff to fall back to the
|
|
257
|
+
# exponential curve. Accepts both RFC 9110 forms: delta-seconds and an
|
|
258
|
+
# HTTP-date.
|
|
259
|
+
def retry_after_seconds(response)
|
|
260
|
+
raw = response.headers&.[]("retry-after") || response.headers&.[]("Retry-After")
|
|
261
|
+
return :backoff if raw.blank?
|
|
262
|
+
|
|
263
|
+
seconds =
|
|
264
|
+
if raw.to_s.strip.match?(/\A\d+(\.\d+)?\z/)
|
|
265
|
+
raw.to_f
|
|
266
|
+
else
|
|
267
|
+
begin
|
|
268
|
+
(Time.httpdate(raw.to_s) - Time.current).to_f
|
|
269
|
+
rescue ArgumentError
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
return :backoff if seconds.nil?
|
|
275
|
+
|
|
276
|
+
[ [ seconds, 0.0 ].max, SolidLoop.config.llm_retry_max_delay ].min
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def backoff_delay(attempt)
|
|
280
|
+
delay = SolidLoop.config.llm_retry_base_delay * (2**(attempt - 1))
|
|
281
|
+
[ delay, SolidLoop.config.llm_retry_max_delay ].min
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Waits in short slices so a pause/stop issued during the backoff is
|
|
285
|
+
# honored within a second rather than after the full delay — the same
|
|
286
|
+
# cancellation contract the in-flight watchdog provides.
|
|
287
|
+
def wait_before_retry(seconds)
|
|
288
|
+
deadline = Time.current + seconds.to_f
|
|
289
|
+
while Time.current < deadline
|
|
290
|
+
if @cancellation_check
|
|
291
|
+
active = begin
|
|
292
|
+
@cancellation_check.call
|
|
293
|
+
rescue StandardError
|
|
294
|
+
true # can't determine status (e.g. test transactions) — assume active
|
|
295
|
+
end
|
|
296
|
+
raise SolidLoop::CancellationError, "Loop cancelled during retry backoff" unless active
|
|
297
|
+
end
|
|
298
|
+
sleep([ 0.25, deadline - Time.current ].min)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
168
301
|
end
|
|
169
302
|
end
|
|
170
303
|
end
|
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
module SolidLoop
|
|
4
4
|
module Dialects
|
|
5
5
|
class Anthropic
|
|
6
|
+
# Anthropic REQUIRES max_tokens on every request (unlike the OpenAI
|
|
7
|
+
# dialect, where an absent key lets the provider pick), so this dialect
|
|
8
|
+
# must supply one when the agent did not. 64k matches the recommended
|
|
9
|
+
# default for STREAMING requests, which is what SolidLoop does by default
|
|
10
|
+
# (`Base#streaming?` is true) — a low cap truncates a turn mid-`tool_use`,
|
|
11
|
+
# which costs a whole agent step, not just a sentence. Current Claude
|
|
12
|
+
# models accept up to 128k output tokens; override per agent via
|
|
13
|
+
# `Base#max_tokens` when you want a tighter ceiling.
|
|
14
|
+
DEFAULT_MAX_TOKENS = 64_000
|
|
15
|
+
|
|
6
16
|
def completion_url(base_url)
|
|
7
17
|
base_url.to_s.gsub(/\/+$/, "") + "/v1/messages"
|
|
8
18
|
end
|
|
@@ -13,13 +23,23 @@ module SolidLoop
|
|
|
13
23
|
h
|
|
14
24
|
end
|
|
15
25
|
|
|
26
|
+
# Anthropic's native shape is a `thinking` content block carrying the
|
|
27
|
+
# signature the server issued with it. That signature is the whole point:
|
|
28
|
+
# Anthropic verifies it, so a replayed thought is provably the model's own
|
|
29
|
+
# and does not have to be re-billed as ordinary text in the body. Messages
|
|
30
|
+
# with no captured signature (anything written before this dialect started
|
|
31
|
+
# storing one) send no reasoning rather than a block that would 400.
|
|
32
|
+
def default_reasoning_strategies
|
|
33
|
+
[ :anthropic_signed ]
|
|
34
|
+
end
|
|
35
|
+
|
|
16
36
|
def apply_reasoning_strategies!(messages, strategies)
|
|
17
|
-
return unless messages.is_a?(Array)
|
|
37
|
+
return SolidLoop::Dialects::ReasoningPacker.empty_report unless messages.is_a?(Array)
|
|
18
38
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
39
|
+
strategies = default_reasoning_strategies if strategies.nil?
|
|
40
|
+
|
|
41
|
+
filtered = Array(strategies).reject { |s| s == :gemini_signed }
|
|
42
|
+
SolidLoop::Dialects::ReasoningPacker.pack_all(messages, filtered)
|
|
23
43
|
end
|
|
24
44
|
|
|
25
45
|
def render_payload(payload)
|
|
@@ -31,13 +51,19 @@ module SolidLoop
|
|
|
31
51
|
|
|
32
52
|
anthropic_payload = {
|
|
33
53
|
model: payload[:model],
|
|
34
|
-
max_tokens: payload[:max_tokens] ||
|
|
54
|
+
max_tokens: payload[:max_tokens] || DEFAULT_MAX_TOKENS,
|
|
35
55
|
messages: merge_tool_results(non_system.map { |m| map_message(m) })
|
|
36
56
|
}.compact
|
|
37
57
|
|
|
38
58
|
anthropic_payload[:system] = system_msg[:content] if system_msg
|
|
39
59
|
anthropic_payload[:temperature] = payload[:temperature] if payload[:temperature]
|
|
40
60
|
|
|
61
|
+
# Anthropic nests the effort level inside output_config rather than
|
|
62
|
+
# taking a top-level parameter the way OpenAI-compatible endpoints do.
|
|
63
|
+
if payload[:reasoning_effort].present?
|
|
64
|
+
anthropic_payload[:output_config] = { effort: payload[:reasoning_effort].to_s }
|
|
65
|
+
end
|
|
66
|
+
|
|
41
67
|
if payload[:tools]&.any?
|
|
42
68
|
anthropic_payload[:tools] = payload[:tools].map { |t| map_tool(t) }
|
|
43
69
|
end
|
|
@@ -55,7 +81,10 @@ module SolidLoop
|
|
|
55
81
|
{
|
|
56
82
|
content: text_block&.dig("text"),
|
|
57
83
|
reasoning: thinking_block&.dig("thinking"),
|
|
58
|
-
tool_calls: map_tool_calls(tool_blocks)
|
|
84
|
+
tool_calls: map_tool_calls(tool_blocks),
|
|
85
|
+
# Kept under the same metadata key the Gemini dialect uses, so
|
|
86
|
+
# MessageBuilding has one place to read a signature from.
|
|
87
|
+
metadata: { "thought_signature" => thinking_block&.dig("signature") }.compact
|
|
59
88
|
}
|
|
60
89
|
end
|
|
61
90
|
|
|
@@ -74,7 +103,7 @@ module SolidLoop
|
|
|
74
103
|
reasoning: res[:reasoning],
|
|
75
104
|
tool_calls: res[:tool_calls],
|
|
76
105
|
usage: usage_data,
|
|
77
|
-
metadata: { "model" => data["model"].presence }.compact
|
|
106
|
+
metadata: res[:metadata].merge({ "model" => data["model"].presence }.compact)
|
|
78
107
|
}
|
|
79
108
|
end
|
|
80
109
|
|
|
@@ -94,8 +123,12 @@ module SolidLoop
|
|
|
94
123
|
}
|
|
95
124
|
when "assistant"
|
|
96
125
|
content = []
|
|
97
|
-
|
|
98
|
-
|
|
126
|
+
# The thinking block must come first, and must carry its signature —
|
|
127
|
+
# the packer only sets the pair together (see ReasoningPacker).
|
|
128
|
+
if m[:thinking].present?
|
|
129
|
+
content << { type: "thinking", thinking: m[:thinking], signature: m[:thinking_signature] }
|
|
130
|
+
end
|
|
131
|
+
content << { type: "text", text: m[:content] } if m[:content].present?
|
|
99
132
|
|
|
100
133
|
if m[:tool_calls].present?
|
|
101
134
|
m[:tool_calls].each do |tc|
|
|
@@ -16,7 +16,11 @@ module SolidLoop
|
|
|
16
16
|
contents: payload[:messages].map { |m| map_message(m) },
|
|
17
17
|
generationConfig: {
|
|
18
18
|
temperature: payload[:temperature],
|
|
19
|
-
maxOutputTokens: payload[:max_tokens]
|
|
19
|
+
maxOutputTokens: payload[:max_tokens],
|
|
20
|
+
# Gemini 3+ takes a discrete thinkingLevel, upper-cased, nested under
|
|
21
|
+
# thinkingConfig. (Gemini 2.5 instead takes a numeric thinkingBudget,
|
|
22
|
+
# and the two are mutually exclusive — sending both is a 400.)
|
|
23
|
+
thinkingConfig: thinking_config(payload)
|
|
20
24
|
}.compact
|
|
21
25
|
}
|
|
22
26
|
if payload[:tools]&.any?
|
|
@@ -25,34 +29,70 @@ module SolidLoop
|
|
|
25
29
|
gemini_payload
|
|
26
30
|
end
|
|
27
31
|
|
|
32
|
+
def thinking_config(payload)
|
|
33
|
+
return nil if payload[:reasoning_effort].blank?
|
|
34
|
+
|
|
35
|
+
{ thinkingLevel: payload[:reasoning_effort].to_s.upcase }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# `:gemini_signed` is the shape this API wants: a `thought` part carrying
|
|
39
|
+
# the `thoughtSignature` Gemini issued with it. Messages with no captured
|
|
40
|
+
# signature send no reasoning rather than an unsigned part Gemini cannot
|
|
41
|
+
# replay.
|
|
42
|
+
def default_reasoning_strategies
|
|
43
|
+
[ :gemini_signed ]
|
|
44
|
+
end
|
|
45
|
+
|
|
28
46
|
def apply_reasoning_strategies!(messages, strategies)
|
|
29
|
-
return unless messages.is_a?(Array)
|
|
47
|
+
return SolidLoop::Dialects::ReasoningPacker.empty_report unless messages.is_a?(Array)
|
|
48
|
+
|
|
49
|
+
strategies = default_reasoning_strategies if strategies.nil?
|
|
30
50
|
|
|
31
51
|
# Gemini filter: only keep gemini-specific and universal XML strategies
|
|
32
|
-
filtered = strategies.select { |s| [:gemini_signed, :xml, :xml!].include?(s) }
|
|
52
|
+
filtered = Array(strategies).select { |s| [ :gemini_signed, :xml, :xml! ].include?(s) }
|
|
33
53
|
|
|
34
|
-
messages
|
|
35
|
-
SolidLoop::Dialects::ReasoningPacker.pack(msg, filtered)
|
|
36
|
-
end
|
|
54
|
+
SolidLoop::Dialects::ReasoningPacker.pack_all(messages, filtered)
|
|
37
55
|
end
|
|
38
56
|
|
|
57
|
+
# `thought` on a Part is a BOOLEAN flag, not the text: a thought part is
|
|
58
|
+
# `{ "thought" => true, "text" => "..." }`, and its text lives in the same
|
|
59
|
+
# `text` key an ordinary answer uses. Reading `part["thought"]` as the
|
|
60
|
+
# reasoning yields `true`, and taking the first part with a `text` yields
|
|
61
|
+
# the thought summary as the visible answer — the reasoning and the reply
|
|
62
|
+
# swap places. Thought parts are therefore filtered out before the answer
|
|
63
|
+
# is read, and only their `text` is collected as reasoning.
|
|
39
64
|
def extract_message_data(data)
|
|
40
65
|
candidate = data.dig("candidates", 0) || {}
|
|
41
66
|
content_obj = candidate.dig("content") || {}
|
|
42
67
|
parts = content_obj.dig("parts") || []
|
|
43
68
|
|
|
44
|
-
|
|
45
|
-
|
|
69
|
+
thought_parts, answer_parts = parts.partition { |p| p["thought"] }
|
|
70
|
+
text_part = answer_parts.find { |p| p["text"].present? }
|
|
46
71
|
tool_calls_part = parts.select { |p| p["functionCall"].present? }
|
|
72
|
+
reasoning = thought_parts.filter_map { |p| p["text"].presence }.join
|
|
47
73
|
|
|
48
74
|
{
|
|
49
75
|
content: text_part&.dig("text"),
|
|
50
|
-
reasoning:
|
|
76
|
+
reasoning: reasoning.presence,
|
|
51
77
|
tool_calls: map_tool_calls_from_gemini(tool_calls_part),
|
|
52
|
-
metadata: { thought_signature
|
|
78
|
+
metadata: { "thought_signature" => extract_thought_signature(parts) }.compact
|
|
53
79
|
}
|
|
54
80
|
end
|
|
55
81
|
|
|
82
|
+
# The signature rides on a Part, never on the candidate — usually the
|
|
83
|
+
# thought part, but on a tool-calling turn Gemini attaches it to the
|
|
84
|
+
# `functionCall` part instead.
|
|
85
|
+
def extract_thought_signature(parts)
|
|
86
|
+
parts.each do |part|
|
|
87
|
+
signature = part["thoughtSignature"] ||
|
|
88
|
+
part["thought_signature"] ||
|
|
89
|
+
part.dig("functionCall", "thoughtSignature") ||
|
|
90
|
+
part.dig("functionCall", "thought_signature")
|
|
91
|
+
return signature if signature.present?
|
|
92
|
+
end
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
56
96
|
def normalize_response(data, fallback_text: "", fallback_messages_text: "")
|
|
57
97
|
res = extract_message_data(data)
|
|
58
98
|
|
|
@@ -83,11 +123,11 @@ module SolidLoop
|
|
|
83
123
|
end
|
|
84
124
|
|
|
85
125
|
parts = []
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
parts <<
|
|
126
|
+
if m[:thought].present?
|
|
127
|
+
# `thought: true` flags the part; the text goes in `text`, exactly as
|
|
128
|
+
# it arrived. The packer only sets :thought together with its
|
|
129
|
+
# signature (see ReasoningPacker).
|
|
130
|
+
parts << { thought: true, text: m[:thought], thoughtSignature: m[:thought_signature] }.compact
|
|
91
131
|
end
|
|
92
132
|
parts << { text: m[:content] } if m[:content].present?
|
|
93
133
|
|
|
@@ -11,15 +11,27 @@ module SolidLoop
|
|
|
11
11
|
api_token.present? ? { "Authorization" => "Bearer #{api_token}" } : {}
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
# The Chat Completions schema has a slot for this — `reasoning_content` on
|
|
15
|
+
# the assistant message, the mirror of the field the server answers with —
|
|
16
|
+
# so use it rather than welding `<think>` into the body. Measured against
|
|
17
|
+
# vLLM's /tokenize on the Qwen3 template, the same six-message
|
|
18
|
+
# conversation costs 1438 prompt tokens welded and 108 in the field; the
|
|
19
|
+
# template drops the field from history, which is what Qwen3's own
|
|
20
|
+
# guidance asks for. Hosts whose provider strips the field can set
|
|
21
|
+
# `reasoning_strategies` back to `[:xml]`.
|
|
22
|
+
def default_reasoning_strategies
|
|
23
|
+
[ "reasoning_content" ]
|
|
24
|
+
end
|
|
25
|
+
|
|
14
26
|
def apply_reasoning_strategies!(messages, strategies)
|
|
15
|
-
return unless messages.is_a?(Array)
|
|
27
|
+
return SolidLoop::Dialects::ReasoningPacker.empty_report unless messages.is_a?(Array)
|
|
28
|
+
|
|
29
|
+
strategies = default_reasoning_strategies if strategies.nil?
|
|
16
30
|
|
|
17
31
|
# OpenAI filter: remove Gemini-specific strategies, allow XML and string fields
|
|
18
|
-
filtered = strategies.reject { |s| s == :gemini_signed }
|
|
32
|
+
filtered = Array(strategies).reject { |s| s == :gemini_signed }
|
|
19
33
|
|
|
20
|
-
messages
|
|
21
|
-
SolidLoop::Dialects::ReasoningPacker.pack(msg, filtered)
|
|
22
|
-
end
|
|
34
|
+
SolidLoop::Dialects::ReasoningPacker.pack_all(messages, filtered)
|
|
23
35
|
end
|
|
24
36
|
|
|
25
37
|
def extract_message_data(data)
|
|
@@ -3,30 +3,95 @@
|
|
|
3
3
|
module SolidLoop
|
|
4
4
|
module Dialects
|
|
5
5
|
class ReasoningPacker
|
|
6
|
+
# An empty carry report: no reasoning was sent back with this request.
|
|
7
|
+
NOTHING_CARRIED = { chars: 0, inline_chars: 0 }.freeze
|
|
8
|
+
|
|
9
|
+
# Strategies whose wire shape is only valid with the signature the
|
|
10
|
+
# provider issued alongside the reasoning.
|
|
11
|
+
SIGNED_STRATEGIES = %i[gemini_signed anthropic_signed].freeze
|
|
12
|
+
|
|
13
|
+
# Applies the configured strategies to a whole message array and reports
|
|
14
|
+
# what the prompt is now carrying. The report is the point: reasoning
|
|
15
|
+
# welded into `content` is charged as prompt tokens on every subsequent
|
|
16
|
+
# turn and compounds, and until it is counted nobody notices. A measured
|
|
17
|
+
# agent loop on Qwen3 was spending 40% of its context window on repacked
|
|
18
|
+
# thoughts the chat template then discarded.
|
|
19
|
+
def self.pack_all(messages, strategies)
|
|
20
|
+
return empty_report unless messages.is_a?(Array)
|
|
21
|
+
|
|
22
|
+
strategies = Array(strategies)
|
|
23
|
+
report = empty_report
|
|
24
|
+
messages.each do |msg|
|
|
25
|
+
carried = pack(msg, strategies)
|
|
26
|
+
next if carried[:chars].zero?
|
|
27
|
+
|
|
28
|
+
report[:chars] += carried[:chars]
|
|
29
|
+
report[:inline_chars] += carried[:inline_chars]
|
|
30
|
+
report[:messages] += 1
|
|
31
|
+
end
|
|
32
|
+
report
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Packs one message. Returns what it carried, in characters — `chars` is
|
|
36
|
+
# everything sent back in any shape, `inline_chars` only the part welded
|
|
37
|
+
# into `content`, which every provider bills.
|
|
6
38
|
def self.pack(msg, strategies)
|
|
7
39
|
reasoning = msg.delete(:_sl_reasoning)
|
|
8
40
|
signature = msg.delete(:_sl_signature)
|
|
9
|
-
return if reasoning.blank?
|
|
41
|
+
return NOTHING_CARRIED.dup if reasoning.blank?
|
|
42
|
+
|
|
43
|
+
# A native strategy only counts as active when it can actually fire.
|
|
44
|
+
# Both signed shapes are rejected by their provider without a valid
|
|
45
|
+
# signature — Anthropic verifies it, Gemini requires it on the part — so
|
|
46
|
+
# an unsigned message falls through to whatever else was configured
|
|
47
|
+
# rather than being sent in a shape that 400s.
|
|
48
|
+
native_fires = strategies.any? { |s| SIGNED_STRATEGIES.include?(s) } && signature.present?
|
|
10
49
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
has_native_strategy = strategies.any? { |s| s == :gemini_signed }
|
|
50
|
+
carried = 0
|
|
51
|
+
inline = 0
|
|
14
52
|
|
|
15
53
|
strategies.each do |strategy|
|
|
16
54
|
case strategy
|
|
17
55
|
when :xml
|
|
18
|
-
# Apply universal fallback ONLY if
|
|
19
|
-
|
|
56
|
+
# Apply universal fallback ONLY if a native strategy actually fired
|
|
57
|
+
next if native_fires
|
|
58
|
+
|
|
59
|
+
apply_xml!(msg, reasoning)
|
|
60
|
+
inline = reasoning.length
|
|
61
|
+
carried = reasoning.length
|
|
20
62
|
when :xml!
|
|
21
63
|
# FORCE XML wrapping regardless of other strategies
|
|
22
64
|
apply_xml!(msg, reasoning)
|
|
65
|
+
inline = reasoning.length
|
|
66
|
+
carried = reasoning.length
|
|
23
67
|
when :gemini_signed
|
|
68
|
+
# An unsigned thought cannot go back to Gemini at all: the signature
|
|
69
|
+
# is what makes the part replayable. Sending the text without it is
|
|
70
|
+
# worse than sending nothing, so drop it.
|
|
71
|
+
next if signature.blank?
|
|
72
|
+
|
|
24
73
|
msg[:thought] = reasoning
|
|
25
|
-
msg[:thought_signature] = signature
|
|
74
|
+
msg[:thought_signature] = signature
|
|
75
|
+
carried = reasoning.length
|
|
76
|
+
when :anthropic_signed
|
|
77
|
+
# Anthropic verifies the signature server-side and rejects a
|
|
78
|
+
# thinking block whose signature is missing or does not match.
|
|
79
|
+
next if signature.blank?
|
|
80
|
+
|
|
81
|
+
msg[:thinking] = reasoning
|
|
82
|
+
msg[:thinking_signature] = signature
|
|
83
|
+
carried = reasoning.length
|
|
26
84
|
when String
|
|
27
85
|
msg[strategy.to_sym] = reasoning
|
|
86
|
+
carried = reasoning.length
|
|
28
87
|
end
|
|
29
88
|
end
|
|
89
|
+
|
|
90
|
+
{ chars: carried, inline_chars: inline }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.empty_report
|
|
94
|
+
{ chars: 0, inline_chars: 0, messages: 0 }
|
|
30
95
|
end
|
|
31
96
|
|
|
32
97
|
def self.apply_xml!(msg, reasoning)
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
module SolidLoop
|
|
2
2
|
class LlmUsageParser
|
|
3
|
+
# llama.cpp's `timings` block. The field names are a trap: `prompt_n` counts
|
|
4
|
+
# only the tokens this request actually had to PROCESS, while `cache_n`
|
|
5
|
+
# counts the ones served from its prompt cache. The full prompt is their sum.
|
|
6
|
+
#
|
|
7
|
+
# Reading `prompt_n` as the prompt (as this did) understates every turn of a
|
|
8
|
+
# long conversation by the whole reused prefix, and the error compounds: in a
|
|
9
|
+
# 30-turn agent loop the recorded prompt fell to a few hundred tokens while
|
|
10
|
+
# the real context was near 37k. The signature is unmistakable in the data —
|
|
11
|
+
# `cache_n` of each turn equals the previous turn's true total, and 127 of
|
|
12
|
+
# 169 recorded turns had `cache_n > prompt_n`, which is impossible if
|
|
13
|
+
# `prompt_n` were the whole prompt.
|
|
3
14
|
class Llama
|
|
4
15
|
def self.call(timings)
|
|
16
|
+
fresh = timings["prompt_n"].to_i
|
|
17
|
+
cached = timings["cache_n"].to_i
|
|
18
|
+
prompt = fresh + cached
|
|
19
|
+
|
|
5
20
|
{
|
|
6
|
-
tokens_prompt:
|
|
21
|
+
tokens_prompt: prompt,
|
|
7
22
|
tokens_completion: timings["predicted_n"].to_i,
|
|
8
|
-
tokens_total:
|
|
9
|
-
tokens_prompt_cached:
|
|
23
|
+
tokens_total: prompt + timings["predicted_n"].to_i,
|
|
24
|
+
tokens_prompt_cached: cached,
|
|
10
25
|
tps: timings["predicted_per_second"].to_f.round(2)
|
|
11
26
|
}
|
|
12
27
|
end
|
|
@@ -164,6 +164,7 @@ module SolidLoop
|
|
|
164
164
|
end
|
|
165
165
|
|
|
166
166
|
def fail_loop(error_msg)
|
|
167
|
+
error_msg = SolidLoop::Redaction.redact_credentials(error_msg)
|
|
167
168
|
# Cleanup 4 — every non-`running` target must clear the LLM lease
|
|
168
169
|
# (lease_expires_at) so the DB CHECK holds and the reaper doesn't re-select
|
|
169
170
|
# a terminal row. Failing during MCP init happens while the loop is still
|
|
@@ -23,7 +23,7 @@ module SolidLoop
|
|
|
23
23
|
max_steps: loop.step_count >= agent.max_steps,
|
|
24
24
|
max_total_tokens: loop.tokens_total >= agent.max_total_tokens,
|
|
25
25
|
max_cost: loop.cost >= agent.max_cost.to_d,
|
|
26
|
-
max_duration: loop.
|
|
26
|
+
max_duration: loop.work_duration >= agent.max_duration.to_i
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
violated = checks.select { |_, hit| hit }
|
|
@@ -32,7 +32,11 @@ module SolidLoop
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def handle_exception(env, e)
|
|
35
|
-
|
|
35
|
+
# Redacted BEFORE it is persisted: `loop.error_message` is surfaced in the
|
|
36
|
+
# admin UI and by hosts' own APIs, and a Faraday error message embeds the
|
|
37
|
+
# request URL — which carries the API key for the Gemini dialect
|
|
38
|
+
# (`?key=…`). Same rule set as the wire log. See SolidLoop::Redaction.
|
|
39
|
+
error_msg = SolidLoop::Redaction.redact_credentials("#{e.class}: #{e.message}")
|
|
36
40
|
Rails.logger.error "ERROR IN MIDDLEWARE CHAIN: #{error_msg}\n#{e.backtrace.take(10).join("\n")}"
|
|
37
41
|
|
|
38
42
|
if e.is_a?(SolidLoop::CancellationError)
|
|
@@ -90,10 +90,10 @@ module SolidLoop
|
|
|
90
90
|
env.event.save!
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
# Shared with Middlewares::ErrorHandling so the wire log and the persisted
|
|
94
|
+
# error text obey the SAME rules. See SolidLoop::Redaction.
|
|
93
95
|
def redact_credentials(value)
|
|
94
|
-
value
|
|
95
|
-
.gsub(/(Authorization|x-api-key|api-key):[^\r\n]*/i, "\\1: [REDACTED]")
|
|
96
|
-
.gsub(/([?&](?:key|api_key|api-key)=)[^&\s]*/i, "\\1[REDACTED]")
|
|
96
|
+
SolidLoop::Redaction.redact_credentials(value)
|
|
97
97
|
end
|
|
98
98
|
end
|
|
99
99
|
end
|