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
|
@@ -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:
|
data/lib/solid_loop/engine.rb
CHANGED
|
@@ -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" =>
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/solid_loop/version.rb
CHANGED
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
|
+
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:
|