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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +445 -0
  3. data/README.md +305 -4
  4. data/Rakefile +5 -4
  5. data/app/controllers/solid_loop/application_controller.rb +6 -0
  6. data/app/controllers/solid_loop/dashboard_controller.rb +167 -22
  7. data/app/controllers/solid_loop/events_controller.rb +9 -1
  8. data/app/controllers/solid_loop/mcp_sessions_controller.rb +12 -0
  9. data/app/controllers/solid_loop/messages_controller.rb +11 -1
  10. data/app/controllers/solid_loop/tool_calls_controller.rb +32 -0
  11. data/app/helpers/solid_loop/application_helper.rb +4 -1
  12. data/app/helpers/solid_loop/metrics_helper.rb +232 -0
  13. data/app/jobs/solid_loop/janitor_job.rb +24 -0
  14. data/app/jobs/solid_loop/llm_completion_job.rb +2 -2
  15. data/app/models/solid_loop/base.rb +89 -6
  16. data/app/models/solid_loop/loop.rb +22 -0
  17. data/app/models/solid_loop/message.rb +33 -7
  18. data/app/models/solid_loop/tool_call.rb +7 -1
  19. data/app/services/solid_loop/adapters/native.rb +156 -23
  20. data/app/services/solid_loop/dialects/anthropic.rb +43 -10
  21. data/app/services/solid_loop/dialects/gemini.rb +55 -15
  22. data/app/services/solid_loop/dialects/open_ai.rb +17 -5
  23. data/app/services/solid_loop/dialects/reasoning_packer.rb +72 -7
  24. data/app/services/solid_loop/llm_usage_parser/llama.rb +18 -3
  25. data/app/services/solid_loop/mcp_session_initializer.rb +1 -0
  26. data/app/services/solid_loop/middlewares/agent_initialization.rb +1 -1
  27. data/app/services/solid_loop/middlewares/error_handling.rb +5 -1
  28. data/app/services/solid_loop/middlewares/event_logging.rb +3 -3
  29. data/app/services/solid_loop/middlewares/message_building.rb +38 -4
  30. data/app/services/solid_loop/middlewares/response_parsing.rb +19 -3
  31. data/app/views/layouts/solid_loop/admin.html.erb +134 -23
  32. data/app/views/solid_loop/dashboard/index.html.erb +330 -41
  33. data/app/views/solid_loop/events/index.html.erb +17 -1
  34. data/app/views/solid_loop/loops/index.html.erb +40 -3
  35. data/app/views/solid_loop/loops/show.html.erb +1 -1
  36. data/app/views/solid_loop/mcp_sessions/index.html.erb +40 -2
  37. data/app/views/solid_loop/messages/_message.html.erb +26 -35
  38. data/app/views/solid_loop/messages/index.html.erb +16 -2
  39. data/app/views/solid_loop/tool_calls/index.html.erb +18 -3
  40. data/db/migrate/20260819000100_solid_loop_add_retention_indexes.rb +22 -0
  41. data/docs/contributing/coverage.md +8 -8
  42. data/docs/decisions/mcp-server.md +4 -2
  43. data/docs/decisions/reasoning_persistence.md +120 -4
  44. data/docs/guides/dialects.md +1 -1
  45. data/docs/guides/mcp_transports.md +72 -0
  46. data/docs/validation.md +85 -0
  47. data/lib/solid_loop/configuration.rb +93 -0
  48. data/lib/solid_loop/engine.rb +8 -0
  49. data/lib/solid_loop/janitor.rb +110 -0
  50. data/lib/solid_loop/mcp/toolset.rb +119 -31
  51. data/lib/solid_loop/pipeline/builder.rb +31 -10
  52. data/lib/solid_loop/redaction.rb +26 -0
  53. data/lib/solid_loop/version.rb +1 -1
  54. data/lib/solid_loop.rb +47 -0
  55. metadata +7 -2
  56. 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: [], dialect: nil, model_name: nil, fallback_messages_text: "", read_timeout: nil, cancellation_check: nil)
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
- @reasoning_strategies = Array(reasoning_strategies)
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
- @request_started_at = Time.current
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
- caller_thread = Thread.current
58
- stop_watchdog = false
59
- _watchdog = build_watchdog(caller_thread, stop_flag: -> { stop_watchdog })
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
- begin
62
- @response = conn.post do |req|
63
- req.headers = headers
64
- req.body = final_payload.to_json
65
-
66
- if @streaming
67
- req.options.on_data = proc do |chunk, _overall_received_bytes|
68
- @debug_buffer.write(chunk)
69
- @first_token_at ||= Time.current
70
- data = parse_stream_chunk(chunk)
71
- emit_chunk_event(data)
72
- end
73
- end
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
- ensure
76
- stop_watchdog = true
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
- filtered = strategies.reject { |s| s == :gemini_signed }
20
- messages.each do |msg|
21
- SolidLoop::Dialects::ReasoningPacker.pack(msg, filtered)
22
- end
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] || 8096,
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
- content << { type: "thinking", thinking: m[:_sl_reasoning] } if m[:_sl_reasoning].present?
98
- content << { type: "text", text: m[:content] } if m[:content].present?
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.each do |msg|
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
- text_part = parts.find { |p| p["text"].present? }
45
- thought_part = parts.find { |p| p["thought"].present? }
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: thought_part&.dig("thought"),
76
+ reasoning: reasoning.presence,
51
77
  tool_calls: map_tool_calls_from_gemini(tool_calls_part),
52
- metadata: { thought_signature: candidate.dig("thoughtSignature") }.compact
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
- thought = m[:thought] || m[:_sl_reasoning]
87
- if thought.present?
88
- thought_part = { thought: thought }
89
- thought_part[:thoughtSignature] = m[:thought_signature] if m[:thought_signature].present?
90
- parts << thought_part
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.each do |msg|
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
- # 1. Determine if we have any native/specific strategies present in the array
12
- # Excluding XML and string-based fields
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 no native strategy is active
19
- apply_xml!(msg, reasoning) unless has_native_strategy
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 if signature.present?
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: timings["prompt_n"].to_i,
21
+ tokens_prompt: prompt,
7
22
  tokens_completion: timings["predicted_n"].to_i,
8
- tokens_total: timings["prompt_n"].to_i + timings["predicted_n"].to_i,
9
- tokens_prompt_cached: timings["cache_n"].to_i,
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.created_at <= agent.max_duration.ago
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
- error_msg = "#{e.class}: #{e.message}"
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.to_s
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