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
@@ -58,6 +58,38 @@ module SolidLoop
58
58
  # `LeaseRenewer#register`). Mirrors `SolidLoop::Base#max_duration`'s default (2h).
59
59
  attr_reader :default_max_duration
60
60
 
61
+ # How many times to RE-send an LLM request that failed transiently, on top of
62
+ # the first attempt. 0 disables retrying. Transient 429/5xx are routine for
63
+ # hosted providers — a shared-model 429 that carried `Retry-After: 30` used to
64
+ # fail the whole loop instantly, discarding the provider's own instruction.
65
+ attr_reader :llm_retries
66
+
67
+ # Backoff base (seconds). Delay for attempt N is `base * 2**(N-1)`, capped at
68
+ # `llm_retry_max_delay`, and OVERRIDDEN by the provider's `Retry-After` when
69
+ # it sends one (still capped) — the provider knows better than the curve.
70
+ attr_reader :llm_retry_base_delay
71
+
72
+ # Ceiling (seconds) on any single backoff wait, `Retry-After` included. A
73
+ # provider asking for a 30-minute wait must not park a worker for 30 minutes.
74
+ attr_reader :llm_retry_max_delay
75
+
76
+ # HTTP statuses treated as transient. 408/429 and the 5xx family; deliberately
77
+ # NOT 400/401/403/404/422 — those are deterministic and retrying only burns
78
+ # quota. Note 409 is included for providers that use it for "model loading".
79
+ attr_reader :llm_retry_statuses
80
+
81
+ # How long to keep `solid_loop_events` (wire logs) and the inbound MCP
82
+ # sessions issued by Mcp::Server. `nil` (the default) means keep forever —
83
+ # unbounded growth is the status quo, and silently deleting a host's audit
84
+ # trail on upgrade would be the wrong default. An ActiveSupport::Duration or
85
+ # a number of seconds. Swept by SolidLoop::Janitor via SolidLoop.prune!.
86
+ attr_reader :event_retention
87
+ attr_reader :mcp_inbound_session_retention
88
+
89
+ # Rows deleted per statement during a retention sweep. Keeps each lock short
90
+ # on tables that are being written to concurrently.
91
+ attr_reader :janitor_batch_size
92
+
61
93
  def initialize
62
94
  @lease_margin = 60
63
95
  @queued_reap_threshold = 300
@@ -68,6 +100,56 @@ module SolidLoop
68
100
  # `max_duration` rather than never.
69
101
  @lease_leak_grace = 300
70
102
  @default_max_duration = 2.hours.to_i
103
+ @llm_retries = 2
104
+ @llm_retry_base_delay = 1.0
105
+ @llm_retry_max_delay = 60.0
106
+ @llm_retry_statuses = [ 408, 409, 429, 500, 502, 503, 504 ].freeze
107
+ @event_retention = nil
108
+ @mcp_inbound_session_retention = nil
109
+ @janitor_batch_size = 1_000
110
+ end
111
+
112
+ def event_retention=(duration)
113
+ @event_retention = normalize_retention(duration, "event_retention")
114
+ end
115
+
116
+ def mcp_inbound_session_retention=(duration)
117
+ @mcp_inbound_session_retention = normalize_retention(duration, "mcp_inbound_session_retention")
118
+ end
119
+
120
+ def janitor_batch_size=(count)
121
+ count = Integer(count)
122
+ raise ArgumentError, "janitor_batch_size must be > 0" unless count.positive?
123
+
124
+ @janitor_batch_size = count
125
+ end
126
+
127
+ def llm_retries=(count)
128
+ count = Integer(count)
129
+ raise ArgumentError, "llm_retries must be >= 0" if count.negative?
130
+
131
+ @llm_retries = count
132
+ end
133
+
134
+ def llm_retry_base_delay=(seconds)
135
+ seconds = Float(seconds)
136
+ raise ArgumentError, "llm_retry_base_delay must be > 0" unless seconds.positive?
137
+
138
+ @llm_retry_base_delay = seconds
139
+ end
140
+
141
+ def llm_retry_max_delay=(seconds)
142
+ seconds = Float(seconds)
143
+ raise ArgumentError, "llm_retry_max_delay must be > 0" unless seconds.positive?
144
+
145
+ @llm_retry_max_delay = seconds
146
+ end
147
+
148
+ def llm_retry_statuses=(statuses)
149
+ statuses = Array(statuses).map { |s| Integer(s) }
150
+ raise ArgumentError, "llm_retry_statuses must not be empty" if statuses.empty?
151
+
152
+ @llm_retry_statuses = statuses.freeze
71
153
  end
72
154
 
73
155
  def lease_margin=(seconds)
@@ -152,6 +234,17 @@ module SolidLoop
152
234
 
153
235
  private
154
236
 
237
+ # nil (keep forever) or a positive Duration. A zero/negative window would
238
+ # delete every row on the next sweep, which is never what an operator means.
239
+ def normalize_retention(duration, name)
240
+ return nil if duration.nil?
241
+
242
+ seconds = duration.respond_to?(:to_i) ? duration.to_i : Integer(duration)
243
+ raise ArgumentError, "#{name} must be > 0 (use nil to keep forever)" unless seconds.positive?
244
+
245
+ duration
246
+ end
247
+
155
248
  # The slowest resolved HTTP MCP client timeout across the agent's `mcps`, or
156
249
  # nil if the agent exposes none / cannot be inspected. Matches the resolution
157
250
  # in Mcp::ClientFactory#transport_for (`mcp_config[:timeout] || 60` for a url:
@@ -7,6 +7,14 @@ module SolidLoop
7
7
  class Engine < ::Rails::Engine
8
8
  isolate_namespace SolidLoop
9
9
 
10
+ # Runs before the host's own `to_prepare` blocks (engine callbacks are
11
+ # registered first), so a host that customizes the stack in `to_prepare`
12
+ # re-applies its changes onto a freshly rebuilt stack rather than onto a
13
+ # stale one — and never accumulates duplicates across reloads.
14
+ config.to_prepare do
15
+ SolidLoop.reset_middlewares!
16
+ end
17
+
10
18
  initializer "solid_loop.mime_types" do
11
19
  Mime::Type.register "text/vnd.turbo-stream.html", :turbo_stream unless Mime[:turbo_stream]
12
20
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ # Age-based retention for the two tables that grow without bound: wire-log
5
+ # Events (one per LLM turn, per tool call and per inbound MCP request) and the
6
+ # McpInboundSessions issued to external clients.
7
+ #
8
+ # Nothing here is on by default — an operator opts in per table, because "how
9
+ # long must the audit trail survive" is a business answer, not a gem's:
10
+ #
11
+ # SolidLoop.configure do |c|
12
+ # c.event_retention = 90.days
13
+ # c.mcp_inbound_session_retention = 30.days
14
+ # end
15
+ #
16
+ # Deletes in batches (`janitor_batch_size`) so a sweep never takes one long
17
+ # lock on a hot, append-only table. Idempotent and safe to run concurrently:
18
+ # two janitors racing on the same batch both issue `delete_all` and the loser
19
+ # simply deletes zero rows.
20
+ #
21
+ # Rows are removed with `delete_all` — no callbacks, no instantiation. That is
22
+ # deliberate for volume, and the only ActiveRecord callback in play would be
23
+ # `McpInboundSession`'s `dependent: :destroy`, which this replaces explicitly
24
+ # by deleting a doomed session's events first (see `prune_inbound_sessions!`).
25
+ #
26
+ # NOTE: the first sweep after enabling retention on an existing install may
27
+ # have years of backlog to clear and can run long. Either let the recurring
28
+ # job chip away at it, or do the initial bulk delete out of band.
29
+ class Janitor
30
+ Result = Struct.new(:events_deleted, :inbound_sessions_deleted, keyword_init: true) do
31
+ def total = events_deleted + inbound_sessions_deleted
32
+ end
33
+
34
+ def initialize(now: Time.current)
35
+ @now = now
36
+ @config = SolidLoop.config
37
+ end
38
+
39
+ def call
40
+ result = Result.new(
41
+ events_deleted: prune_events!,
42
+ inbound_sessions_deleted: prune_inbound_sessions!
43
+ )
44
+ SolidLoop.last_pruned_at = @now
45
+ log(result)
46
+ result
47
+ end
48
+
49
+ private
50
+
51
+ def prune_events!
52
+ retention = @config.event_retention
53
+ return 0 unless retention
54
+
55
+ delete_in_batches(SolidLoop::Event.where(created_at: ...(@now - retention)))
56
+ end
57
+
58
+ # Sessions are pruned by CREATION age, not by `last_used_at` or
59
+ # `terminated_at`: a client that never terminates and never returns would
60
+ # otherwise pin its row forever, which is precisely the leak being closed.
61
+ #
62
+ # A session's events are deleted first. They may still exist even after
63
+ # `prune_events!` — the two retentions are independent, and a shorter session
64
+ # retention would otherwise strand events pointing at a deleted session.
65
+ def prune_inbound_sessions!
66
+ retention = @config.mcp_inbound_session_retention
67
+ return 0 unless retention
68
+
69
+ scope = SolidLoop::McpInboundSession.where(created_at: ...(@now - retention))
70
+ deleted = 0
71
+
72
+ loop do
73
+ ids = scope.limit(batch_size).pluck(:id)
74
+ break if ids.empty?
75
+
76
+ SolidLoop::Event
77
+ .where(eventable_type: "SolidLoop::McpInboundSession", eventable_id: ids)
78
+ .delete_all
79
+ deleted += SolidLoop::McpInboundSession.where(id: ids).delete_all
80
+ end
81
+
82
+ deleted
83
+ end
84
+
85
+ def delete_in_batches(scope)
86
+ deleted = 0
87
+ loop do
88
+ ids = scope.limit(batch_size).pluck(:id)
89
+ break if ids.empty?
90
+
91
+ deleted += scope.model.where(id: ids).delete_all
92
+ end
93
+ deleted
94
+ end
95
+
96
+ def batch_size
97
+ @config.janitor_batch_size
98
+ end
99
+
100
+ def log(result)
101
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
102
+ return if result.total.zero?
103
+
104
+ Rails.logger.info(
105
+ "SolidLoop::Janitor pruned #{result.events_deleted} event(s) and " \
106
+ "#{result.inbound_sessions_deleted} inbound session(s)"
107
+ )
108
+ end
109
+ end
110
+ end
@@ -121,6 +121,28 @@ module SolidLoop
121
121
  name.to_s.demodulize.underscore
122
122
  end
123
123
 
124
+ # The version reported in `initialize` -> serverInfo.version. Defaults to
125
+ # the GEM's version, which is almost never what a host means: semantically
126
+ # this is the version of YOUR MCP server, and leaking the transport's
127
+ # version tells clients nothing while exposing an internal detail.
128
+ #
129
+ # class SearchTools < SolidLoop::Mcp::Toolset
130
+ # server_name "search"
131
+ # server_version "1.0.0"
132
+ # end
133
+ def server_version(value = nil)
134
+ @server_version = value.to_s if value
135
+
136
+ klass = self
137
+ while klass.respond_to?(:own_tools)
138
+ explicit = klass.instance_variable_get(:@server_version)
139
+ return explicit if explicit
140
+ klass = klass.superclass
141
+ end
142
+
143
+ SolidLoop::VERSION
144
+ end
145
+
124
146
  def tool(tool_name, description:, input_schema:, &block)
125
147
  raise ArgumentError, "tool '#{tool_name}' requires a block" unless block
126
148
 
@@ -178,6 +200,10 @@ module SolidLoop
178
200
  body =
179
201
  case request["method"]
180
202
  when "initialize" then handle_initialize(request, context) { |sid| session_id = sid }
203
+ # Base-protocol liveness check: either party may send it at any time and
204
+ # the responder MUST reply promptly with an empty result. Answering
205
+ # "method not found" makes well-behaved clients treat the server as dead.
206
+ when "ping" then success_envelope(request, {})
181
207
  when "tools/list" then success_envelope(request, "tools" => tool_specs)
182
208
  when "tools/call" then handle_tools_call(request, context)
183
209
  when "prompts/list" then success_envelope(request, "prompts" => prompt_specs)
@@ -201,6 +227,93 @@ module SolidLoop
201
227
  nil
202
228
  end
203
229
 
230
+ # --- Dynamic catalogs -----------------------------------------------------
231
+ #
232
+ # The `tool` / `prompt` DSL registers at class-load time, which is wrong for
233
+ # a host whose catalog lives in the database and changes without a deploy.
234
+ # These four instance methods are the seam: they default to the static DSL,
235
+ # and overriding them serves the catalog from anywhere. They are consulted
236
+ # on EVERY request (list, get, call) and by `capabilities`, so a prompt row
237
+ # added a second ago is advertised on the next `prompts/list`.
238
+ #
239
+ # Build entries with the same Structs the DSL uses:
240
+ #
241
+ # def available_prompts
242
+ # SavedPrompt.active.each_with_object({}) do |row, acc|
243
+ # acc[row.name] = PromptDefinition.new(
244
+ # name: row.name, description: row.description, arguments: row.arguments,
245
+ # handler: ->(args, _ctx) { format(row.body, args) }
246
+ # )
247
+ # end
248
+ # end
249
+ #
250
+ # Must be TOTAL: `capabilities` calls `available_prompts` during
251
+ # `initialize`, so a raising override fails the handshake, not one request.
252
+ # Keys are String names; return {} for "none".
253
+ def available_tools
254
+ self.class.tools
255
+ end
256
+
257
+ def find_tool(name)
258
+ available_tools[name.to_s]
259
+ end
260
+
261
+ def available_prompts
262
+ self.class.prompts
263
+ end
264
+
265
+ def find_prompt(name)
266
+ available_prompts[name.to_s]
267
+ end
268
+
269
+ # --- Public extension contract -------------------------------------------
270
+ #
271
+ # `deliver` is the documented override point ("the toolset IS the
272
+ # transport"), so the envelope builders an override needs to answer a
273
+ # request are PUBLIC API and covered by the gem's compatibility promise.
274
+ # They were private, which forced every override to reach through `send` or
275
+ # to depend on private methods that a patch release could rename.
276
+ #
277
+ # def deliver(payload, session_id: nil, context: nil)
278
+ # request = json_boundary(payload)
279
+ # return super unless request["method"] == "resources/list"
280
+ #
281
+ # Result.new(body: success_envelope(request, "resources" => []),
282
+ # session_id: session_id, raw_request: payload,
283
+ # raw_response: nil, status: 200)
284
+ # end
285
+
286
+ def success_envelope(request, result)
287
+ { "jsonrpc" => "2.0", "id" => request["id"], "result" => result }
288
+ end
289
+
290
+ def error_envelope(request, code, message)
291
+ { "jsonrpc" => "2.0", "id" => request["id"], "error" => { "code" => code, "message" => message } }
292
+ end
293
+
294
+ # Round-trips through JSON so symbol keys, Time, BigDecimal and friends
295
+ # arrive at the client exactly as a remote transport would deliver them —
296
+ # in-process and HTTP behavior cannot drift.
297
+ def json_boundary(value)
298
+ JSON.parse(JSON.generate(value))
299
+ end
300
+
301
+ # Wraps a handler's return value in MCP result shape: a Hash becomes both
302
+ # `structuredContent` and its serialized text twin, anything else becomes a
303
+ # single text block.
304
+ def tool_result(value)
305
+ case value
306
+ when Hash
307
+ structured = json_boundary(value)
308
+ {
309
+ "content" => [ { "type" => "text", "text" => JSON.generate(structured) } ],
310
+ "structuredContent" => structured
311
+ }
312
+ else
313
+ { "content" => [ { "type" => "text", "text" => value.to_s } ] }
314
+ end
315
+ end
316
+
204
317
  private
205
318
 
206
319
  def handle_initialize(request, context)
@@ -209,19 +322,19 @@ module SolidLoop
209
322
  success_envelope(request,
210
323
  "protocolVersion" => "2024-11-05",
211
324
  "capabilities" => capabilities,
212
- "serverInfo" => { "name" => self.class.server_name, "version" => SolidLoop::VERSION })
325
+ "serverInfo" => { "name" => self.class.server_name, "version" => self.class.server_version })
213
326
  end
214
327
 
215
328
  # prompts is advertised only when prompts are actually declared.
216
329
  def capabilities
217
330
  caps = { "tools" => {} }
218
- caps["prompts"] = {} if self.class.prompts.any?
331
+ caps["prompts"] = {} if available_prompts.any?
219
332
  caps
220
333
  end
221
334
 
222
335
  def handle_tools_call(request, context)
223
336
  params = request["params"] || {}
224
- definition = self.class.tools[params["name"].to_s]
337
+ definition = find_tool(params["name"])
225
338
  return error_envelope(request, -32602, "Unknown tool: #{params['name']}") unless definition
226
339
 
227
340
  arguments = params["arguments"] || {}
@@ -268,7 +381,7 @@ module SolidLoop
268
381
  # exceptions become a -32603 protocol error instead of propagating.
269
382
  def handle_prompts_get(request, context)
270
383
  params = request["params"] || {}
271
- definition = self.class.prompts[params["name"].to_s]
384
+ definition = find_prompt(params["name"])
272
385
  return error_envelope(request, -32602, "Unknown prompt: #{params['name']}") unless definition
273
386
 
274
387
  arguments = params["arguments"] || {}
@@ -301,7 +414,7 @@ module SolidLoop
301
414
  end
302
415
 
303
416
  def prompt_specs
304
- self.class.prompts.values.map do |p|
417
+ available_prompts.values.map do |p|
305
418
  spec = { "name" => p.name, "description" => p.description }
306
419
  spec["arguments"] = json_boundary(p.arguments) if p.arguments.any?
307
420
  spec
@@ -309,7 +422,7 @@ module SolidLoop
309
422
  end
310
423
 
311
424
  def tool_specs
312
- self.class.tools.values.map do |t|
425
+ available_tools.values.map do |t|
313
426
  {
314
427
  "name" => t.name,
315
428
  "description" => t.description,
@@ -317,31 +430,6 @@ module SolidLoop
317
430
  }
318
431
  end
319
432
  end
320
-
321
- def tool_result(value)
322
- case value
323
- when Hash
324
- structured = json_boundary(value)
325
- {
326
- "content" => [ { "type" => "text", "text" => JSON.generate(structured) } ],
327
- "structuredContent" => structured
328
- }
329
- else
330
- { "content" => [ { "type" => "text", "text" => value.to_s } ] }
331
- end
332
- end
333
-
334
- def success_envelope(request, result)
335
- { "jsonrpc" => "2.0", "id" => request["id"], "result" => result }
336
- end
337
-
338
- def error_envelope(request, code, message)
339
- { "jsonrpc" => "2.0", "id" => request["id"], "error" => { "code" => code, "message" => message } }
340
- end
341
-
342
- def json_boundary(value)
343
- JSON.parse(JSON.generate(value))
344
- end
345
433
  end
346
434
  end
347
435
  end
@@ -1,6 +1,24 @@
1
1
  module SolidLoop
2
2
  class Pipeline
3
3
  class Builder
4
+ # Raised when a positional operation names a middleware the stack does not
5
+ # contain. Placement is the whole point of these methods, so guessing a
6
+ # position is worse than failing: a middleware that lands before
7
+ # AgentInitialization sees an unpopulated context, and one that lands after
8
+ # the terminal link may never run at all. Both fail far from the typo that
9
+ # caused them.
10
+ class UnknownMiddleware < ArgumentError
11
+ def initialize(target, middlewares)
12
+ known = middlewares.map { |m| m.respond_to?(:name) ? m.name : m.inspect }
13
+ super(<<~MSG.strip)
14
+ #{target.inspect} is not in this middleware stack, so there is no position to insert at.
15
+ Known middlewares, in order: #{known.join(', ')}
16
+ If the target looks like it IS there, the stack may be holding class objects
17
+ from an earlier code load — compare by `.equal?`, not by name.
18
+ MSG
19
+ end
20
+ end
21
+
4
22
  attr_reader :middlewares
5
23
 
6
24
  def initialize(middlewares = [])
@@ -12,27 +30,30 @@ module SolidLoop
12
30
  end
13
31
 
14
32
  def insert_before(target, middleware)
15
- index = @middlewares.index(target) || 0
16
- @middlewares.insert(index, middleware)
33
+ @middlewares.insert(index_of!(target), middleware)
17
34
  end
18
35
 
19
36
  def insert_after(target, middleware)
20
- index = @middlewares.index(target) || @middlewares.size - 1
21
- @middlewares.insert(index + 1, middleware)
37
+ @middlewares.insert(index_of!(target) + 1, middleware)
22
38
  end
23
39
 
24
40
  def replace(target, middleware)
25
- index = @middlewares.index(target)
26
- if index
27
- @middlewares[index] = middleware
28
- else
29
- @middlewares << middleware
30
- end
41
+ @middlewares[index_of!(target)] = middleware
31
42
  end
32
43
 
44
+ # Lenient on purpose, unlike the positional methods above: "this middleware
45
+ # should not run" is unambiguous whether or not it is currently present, so
46
+ # a repeated or defensive delete is a legitimate no-op rather than a
47
+ # mistake. Returns the removed middleware, or nil.
33
48
  def delete(target)
34
49
  @middlewares.delete(target)
35
50
  end
51
+
52
+ private
53
+
54
+ def index_of!(target)
55
+ @middlewares.index(target) or raise UnknownMiddleware.new(target, @middlewares)
56
+ end
36
57
  end
37
58
  end
38
59
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ # One redaction rule set for everything that persists provider wire detail —
5
+ # wire logs, event payloads AND the error text written onto a loop/message.
6
+ #
7
+ # It lives here rather than inside EventLogging because the two write paths
8
+ # diverged: EventLogging redacted, while ErrorHandling wrote `e.message`
9
+ # verbatim into `loop.error_message`. That is safe for header-auth dialects
10
+ # (open_ai, anthropic) but NOT for Gemini, which carries the API key in the
11
+ # URL query (`?key=…`) — and a Faraday error message embeds the URL, so the
12
+ # key reached `error_message` and from there the admin UI and any host API
13
+ # that surfaces it.
14
+ module Redaction
15
+ module_function
16
+
17
+ # Header-form credentials (`Authorization: Bearer …`) and key-bearing query
18
+ # parameters (`?key=…`, `&api_key=…`). Returns a String; nil in => "" out,
19
+ # so callers must guard if they need to preserve nil.
20
+ def redact_credentials(value)
21
+ value.to_s
22
+ .gsub(/(Authorization|x-api-key|api-key):[^\r\n]*/i, "\\1: [REDACTED]")
23
+ .gsub(/([?&](?:key|api_key|api-key)=)[^&\s]*/i, "\\1[REDACTED]")
24
+ end
25
+ end
26
+ end
@@ -1,3 +1,3 @@
1
1
  module SolidLoop
2
- VERSION = "0.0.4"
2
+ VERSION = "0.0.5"
3
3
  end
data/lib/solid_loop.rb CHANGED
@@ -2,9 +2,11 @@ require "solid_loop/version"
2
2
  require "solid_loop/engine"
3
3
  require "solid_loop/mcp_client"
4
4
  require "solid_loop/configuration"
5
+ require "solid_loop/redaction"
5
6
  require "solid_loop/lease_renewer"
6
7
  require "solid_loop/lease_heartbeat"
7
8
  require "solid_loop/reaper"
9
+ require "solid_loop/janitor"
8
10
 
9
11
  module SolidLoop
10
12
  class CancellationError < StandardError; end
@@ -24,6 +26,11 @@ module SolidLoop
24
26
  # transaction, so raising here rolls back the enclosing state change instead
25
27
  # of committing it with no successor job to carry the loop forward.
26
28
  class EnqueueError < StandardError; end
29
+ # Raised by `Base#resume!` when the loop is held by an admin freeze. A TYPED
30
+ # error (not a bare StandardError with a magic string) so a host can tell an
31
+ # expected user-facing case — "unfreeze it first", a 422 — apart from a real
32
+ # fault, without matching on message text that the gem is free to reword.
33
+ class FrozenLoopError < StandardError; end
27
34
 
28
35
  class << self
29
36
  # Enqueue a successor job, raising unless ActiveJob confirms it was actually
@@ -46,6 +53,18 @@ module SolidLoop
46
53
  # polymorphic subject (which the engine can't route to on its own). A
47
54
  # callable taking the loop and returning a URL string, or a Hash
48
55
  # { url:, label: }. Left nil by default — the admin degrades to plain text.
56
+ #
57
+ # The callable is invoked plainly (`resolver.call(loop)`), NOT instance_exec'd
58
+ # in a view, so there is no `main_app` proxy in its binding — build paths with
59
+ # `Rails.application.routes.url_helpers`. Return nil for subjects it does not
60
+ # handle, and make it total: an exception here would take down the admin.
61
+ #
62
+ # SolidLoop.subject_resolver = lambda do |loop|
63
+ # next nil unless loop.subject_type == "Order"
64
+ # order = loop.subject or next nil
65
+ # { url: Rails.application.routes.url_helpers.order_path(order),
66
+ # label: "Order ##{order.id}" }
67
+ # end
49
68
  attr_accessor :subject_resolver
50
69
 
51
70
  # Durable-lease + reaper configuration (see docs/decisions/durable_attempt_lease.md).
@@ -61,6 +80,20 @@ module SolidLoop
61
80
  Reaper.new(now: now).call
62
81
  end
63
82
 
83
+ # Apply the configured retention windows, deleting aged-out wire-log Events
84
+ # and inbound MCP sessions. A no-op until a host sets `event_retention` /
85
+ # `mcp_inbound_session_retention`. Idempotent; safe to run concurrently.
86
+ # Returns the SolidLoop::Janitor::Result. Host installs the recurrence via
87
+ # SolidLoop::JanitorJob — the gem does NOT own a scheduler.
88
+ def prune!(now: Time.current)
89
+ Janitor.new(now: now).call
90
+ end
91
+
92
+ # Timestamp of the last completed `prune!` — the retention counterpart of
93
+ # `last_reaped_at`, for hosts alerting on "has the janitor run recently?".
94
+ # Process-local.
95
+ attr_accessor :last_pruned_at
96
+
64
97
  # Timestamp of the last successful `reap!` — a liveness health signal for
65
98
  # hosts to alert on ("has the reaper run recently?"). nil until the first
66
99
  # reap completes. Process-local (each worker tracks its own last run).
@@ -87,6 +120,20 @@ module SolidLoop
87
120
  ])
88
121
  end
89
122
 
123
+ # Drops the memoized middleware stacks so they are rebuilt from freshly
124
+ # loaded constants. The stacks are memoized on this module, which lives in
125
+ # `lib/` and is therefore NEVER reloaded, while the middleware classes live
126
+ # in the engine's `app/` and ARE reloaded in development. Without this reset
127
+ # the memo keeps class objects from an earlier load: `Array#index` compares
128
+ # by `==`, an old class object is not equal to its reloaded replacement, and
129
+ # every positional Builder call against a current constant fails to find its
130
+ # target. The Engine calls this on `to_prepare`, before host initializers
131
+ # re-apply their own customization.
132
+ def reset_middlewares!
133
+ @llm_middlewares = nil
134
+ @tool_middlewares = nil
135
+ end
136
+
90
137
  def configure
91
138
  yield self
92
139
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_loop
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.4
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ruslan
@@ -86,7 +86,9 @@ files:
86
86
  - app/controllers/solid_loop/messages_controller.rb
87
87
  - app/controllers/solid_loop/tool_calls_controller.rb
88
88
  - app/helpers/solid_loop/application_helper.rb
89
+ - app/helpers/solid_loop/metrics_helper.rb
89
90
  - app/jobs/solid_loop/application_job.rb
91
+ - app/jobs/solid_loop/janitor_job.rb
90
92
  - app/jobs/solid_loop/llm_completion_job.rb
91
93
  - app/jobs/solid_loop/observe_broadcast_job.rb
92
94
  - app/jobs/solid_loop/reaper_job.rb
@@ -166,6 +168,7 @@ files:
166
168
  - db/migrate/20260715000300_solid_loop_add_tool_lease.rb
167
169
  - db/migrate/20260715000400_solid_loop_add_lease_running_check.rb
168
170
  - db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb
171
+ - db/migrate/20260819000100_solid_loop_add_retention_indexes.rb
169
172
  - docs/contributing/coverage.md
170
173
  - docs/decisions/durable_attempt_lease.md
171
174
  - docs/decisions/mcp-only-tooling.md
@@ -176,9 +179,11 @@ files:
176
179
  - docs/guides/llm_middlewares.md
177
180
  - docs/guides/mcp_transports.md
178
181
  - docs/guides/tool_middlewares.md
182
+ - docs/validation.md
179
183
  - lib/solid_loop.rb
180
184
  - lib/solid_loop/configuration.rb
181
185
  - lib/solid_loop/engine.rb
186
+ - lib/solid_loop/janitor.rb
182
187
  - lib/solid_loop/lease_heartbeat.rb
183
188
  - lib/solid_loop/lease_renewer.rb
184
189
  - lib/solid_loop/llm_metrics.rb
@@ -198,8 +203,8 @@ files:
198
203
  - lib/solid_loop/pipeline/context.rb
199
204
  - lib/solid_loop/pipeline/tool_context.rb
200
205
  - lib/solid_loop/reaper.rb
206
+ - lib/solid_loop/redaction.rb
201
207
  - lib/solid_loop/version.rb
202
- - lib/tasks/coverage.rake
203
208
  - lib/tasks/solid_loop_tasks.rake
204
209
  homepage: https://github.com/ruslan/solid_loop
205
210
  licenses: