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
data/README.md
CHANGED
|
@@ -6,10 +6,16 @@ Each agent turn (LLM call or tool execution) is a discrete background job. State
|
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
|
-
Add to your Gemfile
|
|
9
|
+
Add to your Gemfile:
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
|
-
gem "solid_loop",
|
|
12
|
+
gem "solid_loop", "~> 0.0.5"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or track the repository directly, pinned to a release tag:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "solid_loop", github: "ruslan/solid_loop", tag: "v0.0.5"
|
|
13
19
|
```
|
|
14
20
|
|
|
15
21
|
Then run:
|
|
@@ -50,7 +56,9 @@ class MyAgent < SolidLoop::Base
|
|
|
50
56
|
|
|
51
57
|
def llm_provider
|
|
52
58
|
{
|
|
53
|
-
|
|
59
|
+
# Origin ONLY — the dialect appends its own path (`/v1/chat/completions`
|
|
60
|
+
# for open_ai). A base_url ending in /v1 would produce /v1/v1/... .
|
|
61
|
+
base_url: "https://api.openai.com",
|
|
54
62
|
api_token: ENV["OPENAI_API_KEY"],
|
|
55
63
|
model: "gpt-4o", # used in the JSON payload (all dialects)
|
|
56
64
|
# llm_model_name: "...", # used for URL construction in the Gemini dialect
|
|
@@ -196,8 +204,103 @@ agent.pause!
|
|
|
196
204
|
agent.resume!
|
|
197
205
|
```
|
|
198
206
|
|
|
207
|
+
`resume!` re-enters `init`, `paused`, `failed` **and `completed`** loops, returning
|
|
208
|
+
`false` for anything else. Resuming a `completed` loop is deliberate — continuing
|
|
209
|
+
a finished conversation is a normal thing to do — but it is the wrong default
|
|
210
|
+
wherever a loop has already reported a final result, because the resumed turn
|
|
211
|
+
appends to it. Narrow it with `from:`:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
agent.resume!(from: %i[paused failed]) # restart what stalled, leave finished runs alone
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Passing a status that can never be resumed (`running`, `queued`) raises
|
|
218
|
+
`ArgumentError` rather than silently never matching. The full set is
|
|
219
|
+
`SolidLoop::Loop::RESUMABLE_STATUSES`.
|
|
220
|
+
|
|
199
221
|
Loop status lifecycle: `init` → `queued` → `running` → `completed` / `failed` / `paused`.
|
|
200
222
|
|
|
223
|
+
### Tools as typed output
|
|
224
|
+
|
|
225
|
+
Some workloads use tools as a *typed output format* rather than as a
|
|
226
|
+
conversation. A batch job hands the model twenty skeleton records and it answers
|
|
227
|
+
with one turn containing twenty atomic tool calls that write them; a labelling
|
|
228
|
+
pass emits one call per item; a codemod emits one call per file.
|
|
229
|
+
|
|
230
|
+
When every call in such a turn succeeds, the model has nothing left to say — but
|
|
231
|
+
the loop schedules another turn anyway, and that turn is a paid request whose
|
|
232
|
+
entire content is the word "done". Over a run of a few hundred batches that adds
|
|
233
|
+
up to a few hundred requests bought for nothing.
|
|
234
|
+
|
|
235
|
+
A loop is normally finished by the model, by returning a turn with no tool calls.
|
|
236
|
+
An agent that already knows the work is done can finish its own loop instead,
|
|
237
|
+
with a middleware and no further request:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
class CompleteOnFullySuccessfulToolTurn
|
|
241
|
+
def initialize(app)
|
|
242
|
+
@app = app
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def call(env)
|
|
246
|
+
last = env.loop.messages.where(role: "assistant").order(id: :desc).first
|
|
247
|
+
calls = last&.tool_calls.to_a
|
|
248
|
+
|
|
249
|
+
if calls.any? && calls.all?(&:is_success)
|
|
250
|
+
env.loop.transition_status(
|
|
251
|
+
from: :running,
|
|
252
|
+
to: :completed,
|
|
253
|
+
expected_execution_token: env.execution_token,
|
|
254
|
+
execution_token: nil,
|
|
255
|
+
lease_expires_at: nil
|
|
256
|
+
)
|
|
257
|
+
return
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
@app.call(env)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
class BatchSeedAgent < SolidLoop::Base
|
|
265
|
+
def configure_llm_middlewares(builder)
|
|
266
|
+
builder.insert_after(
|
|
267
|
+
SolidLoop::Middlewares::AgentInitialization,
|
|
268
|
+
CompleteOnFullySuccessfulToolTurn
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Three details are load-bearing:
|
|
275
|
+
|
|
276
|
+
- **Insert after `AgentInitialization`**, before `NetworkCalling`. The assistant
|
|
277
|
+
shell message is created in `NetworkCalling`; a middleware placed after it
|
|
278
|
+
would strand that shell in `processing` with nothing left to finish it.
|
|
279
|
+
- **Return without calling `@app.call(env)`.** Returning is what skips the
|
|
280
|
+
request. The status transition alone would not.
|
|
281
|
+
- **Keep `expected_execution_token:`.** It is not decoration: without it a worker
|
|
282
|
+
whose generation was already rotated away could complete a loop a newer
|
|
283
|
+
generation now owns.
|
|
284
|
+
|
|
285
|
+
The result is a terminal state indistinguishable from a model-driven completion
|
|
286
|
+
— same `completed` status, `execution_token` and `lease_expires_at` cleared,
|
|
287
|
+
blank `error_message`, no orphaned shell, nothing for the reaper — reached one
|
|
288
|
+
request sooner. `step_count` counts only the turns that actually happened. This
|
|
289
|
+
is covered by `spec/integration/host_driven_completion_spec.rb`.
|
|
290
|
+
|
|
291
|
+
Two things worth keeping:
|
|
292
|
+
|
|
293
|
+
**Do not stop the loop by raising instead.** Raising `LimitExceededError` (or
|
|
294
|
+
setting `max_steps 1`) also ends the turn, but lands the loop in `paused` — which
|
|
295
|
+
`resume!(from: %i[paused failed])`, the documented "restart what stalled" sweep,
|
|
296
|
+
is *supposed* to pick up. Every finished batch would then re-enter and spend the
|
|
297
|
+
request the recipe existed to save. `completed` is not in that sweep.
|
|
298
|
+
|
|
299
|
+
**Test `is_success`, not merely "did every call answer".** An MCP error result is
|
|
300
|
+
persisted with `is_success: false` and remains an ordinary tool response
|
|
301
|
+
precisely so the model can see it and correct itself. Ending the loop on "all
|
|
302
|
+
calls resolved" would cut that short.
|
|
303
|
+
|
|
201
304
|
## Admin UI
|
|
202
305
|
|
|
203
306
|
SolidLoop ships with a mountable admin UI for inspecting loops, messages, tool calls, MCP sessions/tools, events, and a lightweight dashboard.
|
|
@@ -299,8 +402,153 @@ class MyAgent < SolidLoop::Base
|
|
|
299
402
|
end
|
|
300
403
|
```
|
|
301
404
|
|
|
405
|
+
### Customizing the middleware stack
|
|
406
|
+
|
|
407
|
+
`configure_llm_middlewares` / `configure_tool_middlewares` receive a
|
|
408
|
+
`Pipeline::Builder`. Positional edits (`insert_before`, `insert_after`,
|
|
409
|
+
`replace`) raise `Pipeline::Builder::UnknownMiddleware` if the named middleware
|
|
410
|
+
is not in the stack — placement is the point, so a missed target is an error
|
|
411
|
+
rather than a guess. `delete` is lenient and no-ops when the target is absent.
|
|
412
|
+
|
|
413
|
+
```ruby
|
|
414
|
+
def configure_tool_middlewares(builder)
|
|
415
|
+
builder.insert_after(SolidLoop::ToolMiddlewares::ToolExecution, MyMiddleware)
|
|
416
|
+
end
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Order matters: anything inserted before `AgentInitialization` runs against an
|
|
420
|
+
unpopulated context. The engine rebuilds the stacks on every reload, so a host
|
|
421
|
+
`to_prepare` block that customizes them re-applies onto a fresh stack rather
|
|
422
|
+
than accumulating duplicates.
|
|
423
|
+
|
|
424
|
+
### Extra payload parameters
|
|
425
|
+
|
|
426
|
+
SolidLoop builds a deliberately small payload — `model`, `messages`, `stream`,
|
|
427
|
+
and `tools`. Anything else the provider accepts comes from the agent's
|
|
428
|
+
`llm_params`, deep-merged over that payload as the last step before the dialect
|
|
429
|
+
renders it:
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
class MyAgent < SolidLoop::Base
|
|
433
|
+
def llm_params
|
|
434
|
+
{ reasoning_effort: "low", temperature: 0.2, max_tokens: 8192 }
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Two knobs have first-class hooks because they need per-dialect translation:
|
|
440
|
+
`reasoning_effort` and `max_tokens`.
|
|
441
|
+
|
|
442
|
+
```ruby
|
|
443
|
+
class MyAgent < SolidLoop::Base
|
|
444
|
+
def reasoning_effort = "low" # nil (default) sends nothing
|
|
445
|
+
end
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
| Dialect | Wire shape |
|
|
449
|
+
|---------|------------|
|
|
450
|
+
| `open_ai` | `reasoning_effort: "low"` |
|
|
451
|
+
| `anthropic` | `output_config: { effort: "low" }` |
|
|
452
|
+
| `gemini` | `generationConfig.thinkingConfig.thinkingLevel: "LOW"` |
|
|
453
|
+
|
|
454
|
+
Accepted levels differ by provider, so SolidLoop passes the value through rather
|
|
455
|
+
than validating it. Note that `llm_params` cannot substitute for this hook on
|
|
456
|
+
the `anthropic` and `gemini` dialects: both rebuild the payload in
|
|
457
|
+
`render_payload` and drop keys they do not recognize.
|
|
458
|
+
|
|
459
|
+
`max_tokens` has its own hook (`SolidLoop::Base#max_tokens`) and defaults to
|
|
460
|
+
`nil` — no cap is sent and the provider applies its own default. Override it to
|
|
461
|
+
opt in, or set it through `llm_params` like any other key.
|
|
462
|
+
|
|
463
|
+
Because the merge happens last, `llm_params` can also override a key the gem
|
|
464
|
+
set. Under the `open_ai` dialect the keys reach the wire verbatim; the
|
|
465
|
+
`anthropic` and `gemini` dialects re-render the payload, so only the keys those
|
|
466
|
+
dialects read survive.
|
|
467
|
+
|
|
302
468
|
The native `anthropic` and `gemini` dialects are still useful for non-streaming production use (background agents, batch jobs) where you control the provider directly without an extra hop.
|
|
303
469
|
|
|
470
|
+
### Reasoning from earlier turns
|
|
471
|
+
|
|
472
|
+
Most providers treat reasoning as output-only: they hand back a `<think>` block
|
|
473
|
+
or a `reasoning_content` field, and the next request has to decide what to do
|
|
474
|
+
with it. Drop it and the model forgets its own plan; send it back the wrong way
|
|
475
|
+
and you pay for it on every turn from then on.
|
|
476
|
+
|
|
477
|
+
`SolidLoop::Base#reasoning_strategies` picks the shape. It defaults to `nil`,
|
|
478
|
+
which means "let the dialect choose":
|
|
479
|
+
|
|
480
|
+
| Dialect | Default | Wire shape |
|
|
481
|
+
|---------|---------|------------|
|
|
482
|
+
| `open_ai` | `["reasoning_content"]` | `reasoning_content` on the assistant message |
|
|
483
|
+
| `anthropic` | `[:anthropic_signed]` | `{ type: "thinking", thinking:, signature: }` block, first in `content` |
|
|
484
|
+
| `gemini` | `[:gemini_signed]` | `{ thought: true, text:, thoughtSignature: }` part |
|
|
485
|
+
|
|
486
|
+
```ruby
|
|
487
|
+
class MyAgent < SolidLoop::Base
|
|
488
|
+
def reasoning_strategies
|
|
489
|
+
[ "reasoning_content" ] # an explicit field
|
|
490
|
+
# [:xml] # weld into content — works anywhere
|
|
491
|
+
# [:xml!] # weld even alongside a native strategy
|
|
492
|
+
# [:gemini_signed] # signed thought part
|
|
493
|
+
# [:anthropic_signed] # signed thinking block
|
|
494
|
+
# [] # send no reasoning back at all
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
**`:xml` is not free.** It welds every past thought into the assistant message
|
|
500
|
+
body, so it is billed as prompt tokens on every subsequent turn and compounds
|
|
501
|
+
over the run. Measured on vLLM's `/tokenize` against the Qwen3 chat template,
|
|
502
|
+
the same six-message conversation cost **1438** prompt tokens welded and **108**
|
|
503
|
+
in `reasoning_content` — and 108 with the reasoning omitted entirely, because
|
|
504
|
+
that template drops the field from history exactly as the model's own guidance
|
|
505
|
+
asks ("historical model output should only include the final output part").
|
|
506
|
+
|
|
507
|
+
Note what that equality means: the two options are not the same request with a
|
|
508
|
+
different encoding. Welding puts the old thoughts in front of the model as
|
|
509
|
+
ordinary text; the field, on this template, does not. So `:xml` buys re-showing
|
|
510
|
+
the model its own past reasoning, and on Qwen3 it costs ~13x the tokens for
|
|
511
|
+
something the model's authors advise against — and a long loop can end up
|
|
512
|
+
spending a large fraction of its context window on it. Prefer the field where
|
|
513
|
+
the provider has one; keep `:xml` where the provider strips it, or where you
|
|
514
|
+
have measured that the model does better for having its old thoughts back.
|
|
515
|
+
|
|
516
|
+
**The signed strategies need their signature.** Anthropic and Gemini both issue
|
|
517
|
+
one alongside the reasoning and verify it when the reasoning comes back; the
|
|
518
|
+
dialects capture it into `message.metadata["thought_signature"]`. A message
|
|
519
|
+
without one — anything written before 0.0.5, or by a model that returns no
|
|
520
|
+
signature — sends **no reasoning at all** rather than a shape the provider would
|
|
521
|
+
reject. Pair with `:xml` (`[:anthropic_signed, :xml]`) if you would rather weld
|
|
522
|
+
the unsigned ones into the body than lose them: the `:xml` fallback fires
|
|
523
|
+
exactly when the signed strategy could not.
|
|
524
|
+
|
|
525
|
+
Both signed shapes are built from the providers' published documentation and
|
|
526
|
+
covered by unit tests, but neither has been exercised against a live key — see
|
|
527
|
+
[docs/validation.md](docs/validation.md). On `anthropic` or `gemini`, set
|
|
528
|
+
`reasoning_strategies` to `[:xml]` to keep the pre-0.0.5 behaviour while you
|
|
529
|
+
evaluate.
|
|
530
|
+
|
|
531
|
+
**Do not trim reasoning turn by turn.** It is the obvious idea and it is a trap:
|
|
532
|
+
a sliding retained window rewrites the prompt in the *middle* every turn, so the
|
|
533
|
+
prefix cache diverges at the message that just aged out and everything after it
|
|
534
|
+
is prefilled again. Measured on llama.cpp (Qwen3.8-27B Q6_K, prefix caching on)
|
|
535
|
+
over an 8-step agent chain — keeping the newest 2 turns' reasoning cost **1847
|
|
536
|
+
ms** of prefill per turn against **910 ms** for the untrimmed history it was
|
|
537
|
+
meant to be cheaper than, and 500 ms for sending none. Trimming is not the
|
|
538
|
+
problem; *sliding* is. A stable rule (always all, or always none) keeps the
|
|
539
|
+
prompt monotonically extending and the cache intact.
|
|
540
|
+
|
|
541
|
+
So there are two honest positions: carry the reasoning and let the cache pay for
|
|
542
|
+
it, or send none. When a run genuinely outgrows its window, the answer is
|
|
543
|
+
compaction — summarise the old history once, keep a recent tail, resume
|
|
544
|
+
extending — not a per-turn trim. codex and opencode both do exactly this, and
|
|
545
|
+
SolidLoop does not have it yet.
|
|
546
|
+
|
|
547
|
+
Whatever the strategy, the cost is recorded. Each assistant message's `metadata`
|
|
548
|
+
carries a `reasoning_carried` entry describing what that request's prompt spent
|
|
549
|
+
on earlier reasoning — `messages`, `chars`, `inline_chars` (the part welded into
|
|
550
|
+
`content`) and `tokens_est`.
|
|
551
|
+
|
|
304
552
|
## Read Timeout
|
|
305
553
|
|
|
306
554
|
`read_timeout` controls how long to wait for data from the LLM before aborting. It is set per-agent via `llm_provider` and defaults to 10 minutes.
|
|
@@ -366,7 +614,7 @@ SolidLoop routes its jobs onto three fixed queues. A host that runs a worker wit
|
|
|
366
614
|
| --- | --- | --- |
|
|
367
615
|
| `solid_loop_llm` | `LlmCompletionJob` (LLM turns) | loops never advance |
|
|
368
616
|
| `solid_loop_tool` | `ToolExecutionJob` (tool calls) | tool phases never resolve |
|
|
369
|
-
| `solid_loop_maintenance` | `ReaperJob` (recovery sweep) | **hard-kill recovery is lost** — dead turns/tools are never reclaimed, and
|
|
617
|
+
| `solid_loop_maintenance` | `ReaperJob` (recovery sweep), `JanitorJob` (retention sweep) | **hard-kill recovery is lost** — dead turns/tools are never reclaimed, and maintenance jobs accumulate unrun |
|
|
370
618
|
|
|
371
619
|
The `ReaperJob` queue name (`solid_loop_maintenance`) is a **permanent, stable contract**. For example, a GoodJob worker that allowlists queues must include all three:
|
|
372
620
|
|
|
@@ -386,9 +634,55 @@ SolidLoop.configure do |c|
|
|
|
386
634
|
c.config.default_tool_timeout = 600 # ceiling for an in-process/custom tool's lease (raise above your slowest such tool)
|
|
387
635
|
c.config.lease_leak_grace = 300 # grace (s) added on top of an agent's max_duration to form the renewer's leak ceiling — a leak-safety backstop; must be > 0
|
|
388
636
|
c.config.default_max_duration = 7200 # fallback max_duration (s) sizing the leak ceiling when a registration omits one (mirrors SolidLoop::Base#max_duration)
|
|
637
|
+
|
|
638
|
+
# Transient LLM failures (see below)
|
|
639
|
+
c.config.llm_retries = 2 # re-sends on top of the first attempt; 0 disables
|
|
640
|
+
c.config.llm_retry_base_delay = 1.0 # backoff base (s); delay for attempt N is base * 2**(N-1)
|
|
641
|
+
c.config.llm_retry_max_delay = 60.0 # ceiling on any single wait, `Retry-After` included
|
|
642
|
+
c.config.llm_retry_statuses = [408, 409, 429, 500, 502, 503, 504]
|
|
643
|
+
|
|
644
|
+
# Retention (see below) — nil means keep forever, which is the default
|
|
645
|
+
c.config.event_retention = nil # e.g. 90.days
|
|
646
|
+
c.config.mcp_inbound_session_retention = nil # e.g. 30.days
|
|
647
|
+
c.config.janitor_batch_size = 1000
|
|
389
648
|
end
|
|
390
649
|
```
|
|
391
650
|
|
|
651
|
+
### Transient provider failures
|
|
652
|
+
|
|
653
|
+
A 429 or 5xx from a hosted provider is routine, and providers often say exactly how long to wait. The adapter re-sends such a response up to `llm_retries` times before the turn is allowed to fail, honouring the response's `Retry-After` (both delta-seconds and HTTP-date forms) over its own exponential backoff, capped by `llm_retry_max_delay`. Deterministic statuses — 400/401/403/404/422 — are never retried; repeating them only burns quota.
|
|
654
|
+
|
|
655
|
+
Two boundaries are deliberate:
|
|
656
|
+
|
|
657
|
+
- **A read timeout is not retried.** By the time it fires the full `read_timeout` has already elapsed and the request may well have been served; repeating it would multiply the turn's wall clock. Only a response with a retryable status, or a connection that never established (`Faraday::ConnectionFailed`), is re-sent.
|
|
658
|
+
- **A stream that has already emitted is not retried.** Once a chunk reaches the caller the assistant message shell holds partial content, and re-sending would concatenate two generations. The latch is set at the point content is handed over, not merely when bytes arrive — so a streamed *error body* stays retryable.
|
|
659
|
+
|
|
660
|
+
Backoff waits are cancellation-aware: a pause or stop issued during the wait is honoured within a second rather than after the full delay. Retries are recorded in the turn's wire log; `ttft` and `duration` describe the attempt that produced the answer.
|
|
661
|
+
|
|
662
|
+
### Retention
|
|
663
|
+
|
|
664
|
+
`solid_loop_events` (one row per LLM turn, tool call and inbound MCP request) and `solid_loop_mcp_inbound_sessions` grow without bound. Retention is **off by default** — silently deleting a host's audit trail on upgrade would be the wrong default — and is opted into per table:
|
|
665
|
+
|
|
666
|
+
```ruby
|
|
667
|
+
SolidLoop.configure do |c|
|
|
668
|
+
c.config.event_retention = 90.days
|
|
669
|
+
c.config.mcp_inbound_session_retention = 30.days
|
|
670
|
+
end
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
Install the recurrence like the reaper's; unlike the reaper this is a daily concern, not a per-minute one:
|
|
674
|
+
|
|
675
|
+
```ruby
|
|
676
|
+
config.good_job.cron = {
|
|
677
|
+
solid_loop_reaper: { cron: "* * * * *", class: "SolidLoop::ReaperJob" },
|
|
678
|
+
solid_loop_janitor: { cron: "17 4 * * *", class: "SolidLoop::JanitorJob" }
|
|
679
|
+
}
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
`SolidLoop.prune!` deletes in batches of `janitor_batch_size` so a sweep never takes one long lock on a table being written to concurrently. It is idempotent and safe to run concurrently. Sessions are pruned by **creation** age rather than last use, so a client that never terminates and never returns cannot pin its row forever; a doomed session's events are removed with it even when `event_retention` is unset, so no event is left pointing at a deleted session. `SolidLoop.last_pruned_at` is the liveness signal, mirroring `last_reaped_at` (**process-local**).
|
|
683
|
+
|
|
684
|
+
The first sweep after enabling retention on an existing install may have years of backlog to clear and can run long — either let the recurring job chip away at it, or do the initial bulk delete out of band.
|
|
685
|
+
|
|
392
686
|
The invariant `lease_margin > 0` guarantees `lease > read_timeout` (LLM) and `lease > tool timeout` (tools) per claim; the heartbeat then keeps a healthy LLM turn's lease alive for its full duration. The `default_read_timeout` knob is the **single coherent source** for BOTH the derived LLM lease and the HTTP client's fallback read timeout, so the two can never diverge.
|
|
393
687
|
|
|
394
688
|
### The LLM lease renewer (one shared background thread)
|
|
@@ -479,5 +773,12 @@ loop_record = TestAgent.start!(
|
|
|
479
773
|
)
|
|
480
774
|
```
|
|
481
775
|
|
|
776
|
+
### What it has actually run against
|
|
777
|
+
|
|
778
|
+
The suite stubs every provider. For the other half of the picture — the models,
|
|
779
|
+
inference servers and hardware SolidLoop has been exercised on before a release,
|
|
780
|
+
and the paths that are still untested against a live key — see
|
|
781
|
+
[docs/validation.md](docs/validation.md).
|
|
782
|
+
|
|
482
783
|
## License
|
|
483
784
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
CHANGED
|
@@ -5,7 +5,8 @@ load "rails/tasks/engine.rake"
|
|
|
5
5
|
|
|
6
6
|
require "bundler/gem_tasks"
|
|
7
7
|
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
# Development-only tasks live OUTSIDE lib/tasks/ so `Rails::Engine` never loads
|
|
9
|
+
# them into a host application's Rake (where same-named tasks merge and
|
|
10
|
+
# top-level constants collide). They are the gem's own tooling and are not
|
|
11
|
+
# packaged — see the gemspec's file list.
|
|
12
|
+
Dir[File.expand_path("tasks/*.rake", __dir__)].sort.each { |f| load f }
|
|
@@ -2,6 +2,12 @@ module SolidLoop
|
|
|
2
2
|
class ApplicationController < ActionController::Base
|
|
3
3
|
layout "solid_loop/admin"
|
|
4
4
|
|
|
5
|
+
# `SolidLoop::ApplicationHelper` is picked up by Rails' name convention
|
|
6
|
+
# (ApplicationController -> ApplicationHelper); anything else has to be
|
|
7
|
+
# declared, because an isolated engine's `app/helpers` is not added to the
|
|
8
|
+
# host application's helpers path.
|
|
9
|
+
helper SolidLoop::MetricsHelper
|
|
10
|
+
|
|
5
11
|
private
|
|
6
12
|
|
|
7
13
|
def paginate(scope, per_page: 50)
|
|
@@ -1,26 +1,40 @@
|
|
|
1
1
|
module SolidLoop
|
|
2
2
|
class DashboardController < ApplicationController
|
|
3
|
+
PERIODS = %w[hour day week].freeze
|
|
4
|
+
|
|
5
|
+
# Operational order: what is happening now first, what is done last.
|
|
6
|
+
LOOP_STATUS_ORDER = %w[running queued init paused completed failed].freeze
|
|
7
|
+
|
|
3
8
|
def index
|
|
4
|
-
@period = params[:period]
|
|
9
|
+
@period = normalize_period(params[:period])
|
|
10
|
+
@window_label = window_label(@period)
|
|
5
11
|
end
|
|
6
12
|
|
|
7
13
|
def stats
|
|
8
|
-
period = params[:period]
|
|
14
|
+
period = normalize_period(params[:period])
|
|
9
15
|
|
|
10
16
|
render json: {
|
|
11
17
|
period: period,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
window_label: window_label(period),
|
|
19
|
+
buckets: buckets(period),
|
|
20
|
+
llm_calls: fill_buckets(llm_calls_by_period(period), period),
|
|
21
|
+
tool_calls: fill_buckets(tool_calls_by_period(period), period),
|
|
22
|
+
cost: fill_buckets(cost_by_period(period), period, default: 0.0),
|
|
15
23
|
tokens: tokens_by_period(period),
|
|
16
24
|
top_agents: top_agents_by_cost(period),
|
|
17
|
-
failed: failed_by_period(period),
|
|
25
|
+
failed: fill_buckets(failed_by_period(period), period),
|
|
26
|
+
loop_statuses: loop_statuses(period),
|
|
27
|
+
throughput: throughput(period),
|
|
18
28
|
summary: summary_stats(period)
|
|
19
29
|
}
|
|
20
30
|
end
|
|
21
31
|
|
|
22
32
|
private
|
|
23
33
|
|
|
34
|
+
def normalize_period(value)
|
|
35
|
+
PERIODS.include?(value) ? value : "hour"
|
|
36
|
+
end
|
|
37
|
+
|
|
24
38
|
def sql_period(period)
|
|
25
39
|
case period
|
|
26
40
|
when "day" then "date_trunc('day', created_at)"
|
|
@@ -29,40 +43,125 @@ module SolidLoop
|
|
|
29
43
|
end
|
|
30
44
|
end
|
|
31
45
|
|
|
46
|
+
# Start of the reporting window, aligned DOWN to a bucket boundary.
|
|
47
|
+
#
|
|
48
|
+
# A raw `24.hours.ago` opens the window mid-bucket, which costs twice: the
|
|
49
|
+
# oldest column is a partial hour drawn next to whole ones, and the axis
|
|
50
|
+
# needs 25 hourly buckets to cover 24 hours — two of which format to the
|
|
51
|
+
# same label ("9 PM"). Chartkick keys its categorical series BY LABEL, so
|
|
52
|
+
# the duplicate silently collapsed and the current hour's bar was drawn on
|
|
53
|
+
# top of yesterday's, 24 columns to the left of where it belongs.
|
|
54
|
+
#
|
|
55
|
+
# Aligned, the window is exactly 24 / 30 / 12 whole buckets with unique
|
|
56
|
+
# labels, and the KPI totals equal the sum of the bars beneath them.
|
|
32
57
|
def interval(period)
|
|
58
|
+
now = Time.current.utc
|
|
59
|
+
|
|
33
60
|
case period
|
|
34
|
-
when "day" then
|
|
35
|
-
when "week" then
|
|
36
|
-
else
|
|
61
|
+
when "day" then bucket_floor("day", now) - 29.days
|
|
62
|
+
when "week" then bucket_floor("week", now) - 11.weeks
|
|
63
|
+
else bucket_floor("hour", now) - 23.hours
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Human name for the window `interval` opens, so every number on the page can
|
|
68
|
+
# say what it is counting instead of leaving the reader to guess.
|
|
69
|
+
def window_label(period)
|
|
70
|
+
case period
|
|
71
|
+
when "day" then "last 30 days"
|
|
72
|
+
when "week" then "last 12 weeks"
|
|
73
|
+
else "last 24 hours"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# --- Shared time axis -------------------------------------------------
|
|
78
|
+
#
|
|
79
|
+
# Every series is grouped with `date_trunc`, which only emits buckets that
|
|
80
|
+
# actually have rows. Charts drawn from those hashes therefore each get their
|
|
81
|
+
# OWN x-axis: Tokens would show 5PM/7PM/8PM while LLM Calls showed 5–9PM, and
|
|
82
|
+
# the two panels could not be compared by eye. We generate the full bucket
|
|
83
|
+
# list for the selected window once and left-join every series onto it, so a
|
|
84
|
+
# quiet hour renders as a visible zero and all panels share one axis.
|
|
85
|
+
|
|
86
|
+
def bucket_step(period)
|
|
87
|
+
case period
|
|
88
|
+
when "day" then 1.day
|
|
89
|
+
when "week" then 1.week
|
|
90
|
+
else 1.hour
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def bucket_floor(period, time)
|
|
95
|
+
case period
|
|
96
|
+
when "day" then time.beginning_of_day
|
|
97
|
+
# Postgres `date_trunc('week', ...)` is ISO: weeks start Monday. Pin the
|
|
98
|
+
# Ruby side to Monday too rather than inheriting a host's
|
|
99
|
+
# `Date.beginning_of_week`, or generated keys would miss the data keys.
|
|
100
|
+
when "week" then time.beginning_of_week(:monday)
|
|
101
|
+
else time.beginning_of_hour
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Canonical string form of a bucket. Matches how Rails serializes the Time
|
|
106
|
+
# keys `group(date_trunc(...))` returns, so generated and queried buckets
|
|
107
|
+
# collide exactly ("2026-08-18T21:00:00.000Z") and the browser can parse it.
|
|
108
|
+
def bucket_key(value)
|
|
109
|
+
time = value.is_a?(String) ? Time.zone.parse(value) : value
|
|
110
|
+
time.to_time.getutc.iso8601(3)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def buckets(period)
|
|
114
|
+
@buckets ||= {}
|
|
115
|
+
@buckets[period] ||= begin
|
|
116
|
+
step = bucket_step(period)
|
|
117
|
+
cursor = bucket_floor(period, interval(period).utc)
|
|
118
|
+
last = bucket_floor(period, Time.current.utc)
|
|
119
|
+
|
|
120
|
+
keys = []
|
|
121
|
+
while cursor <= last
|
|
122
|
+
keys << bucket_key(cursor)
|
|
123
|
+
cursor += step
|
|
124
|
+
end
|
|
125
|
+
keys
|
|
37
126
|
end
|
|
38
127
|
end
|
|
39
128
|
|
|
129
|
+
def fill_buckets(hash, period, default: 0)
|
|
130
|
+
normalized = hash.each_with_object({}) do |(key, value), acc|
|
|
131
|
+
acc[bucket_key(key)] = value
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
buckets(period).index_with { |key| normalized.fetch(key, default) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# --- Series -----------------------------------------------------------
|
|
138
|
+
|
|
40
139
|
def llm_calls_by_period(period)
|
|
41
|
-
SolidLoop::Event.where(name: "llm_completion").where("created_at
|
|
140
|
+
SolidLoop::Event.where(name: "llm_completion").where("created_at >= ?", interval(period))
|
|
42
141
|
.group(sql_period(period)).count
|
|
43
142
|
end
|
|
44
143
|
|
|
45
144
|
def tool_calls_by_period(period)
|
|
46
|
-
SolidLoop::Event.where(name: "mcp_tool_call").where("created_at
|
|
145
|
+
SolidLoop::Event.where(name: "mcp_tool_call").where("created_at >= ?", interval(period))
|
|
47
146
|
.group(sql_period(period)).count
|
|
48
147
|
end
|
|
49
148
|
|
|
50
149
|
def cost_by_period(period)
|
|
51
|
-
SolidLoop::Loop.where("created_at
|
|
150
|
+
SolidLoop::Loop.where("created_at >= ?", interval(period))
|
|
52
151
|
.group(sql_period(period)).sum(:cost)
|
|
152
|
+
.transform_values(&:to_f)
|
|
53
153
|
end
|
|
54
154
|
|
|
55
155
|
def tokens_by_period(period)
|
|
56
|
-
|
|
57
|
-
loops = SolidLoop::Loop.where("created_at > ?", interval(period))
|
|
156
|
+
loops = SolidLoop::Loop.where("created_at >= ?", interval(period))
|
|
58
157
|
.group(sql_period(period))
|
|
59
158
|
.sum(:tokens_total)
|
|
60
|
-
|
|
61
|
-
[{ name: "Tokens", data: loops }]
|
|
159
|
+
|
|
160
|
+
[ { name: "Tokens", data: fill_buckets(loops, period) } ]
|
|
62
161
|
end
|
|
63
162
|
|
|
64
163
|
def top_agents_by_cost(period)
|
|
65
|
-
SolidLoop::Loop.where("created_at
|
|
164
|
+
SolidLoop::Loop.where("created_at >= ?", interval(period))
|
|
66
165
|
.group(:agent_class_name)
|
|
67
166
|
.order(Arel.sql("sum_cost DESC"))
|
|
68
167
|
.limit(5)
|
|
@@ -70,17 +169,63 @@ module SolidLoop
|
|
|
70
169
|
end
|
|
71
170
|
|
|
72
171
|
def failed_by_period(period)
|
|
73
|
-
SolidLoop::Loop.where(status: "failed").where("created_at
|
|
172
|
+
SolidLoop::Loop.where(status: "failed").where("created_at >= ?", interval(period))
|
|
74
173
|
.group(sql_period(period)).count
|
|
75
174
|
end
|
|
76
175
|
|
|
176
|
+
# Loop population by lifecycle state. Never surfaced anywhere in the admin
|
|
177
|
+
# before, yet it is the first question an operator asks ("is anything stuck
|
|
178
|
+
# running? is the queue draining?"). Always returns every status, including
|
|
179
|
+
# the ones at zero, so the shape of the row does not jump around between
|
|
180
|
+
# polls and an absent status reads as "none" rather than "not measured".
|
|
181
|
+
def loop_statuses(period)
|
|
182
|
+
counts = SolidLoop::Loop.where("created_at >= ?", interval(period)).group(:status).count
|
|
183
|
+
LOOP_STATUS_ORDER.index_with { |status| counts[status].to_i }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Generation throughput: tokens per second and time to first token, the two
|
|
187
|
+
# numbers that actually differ when you swap a model or a GPU. Both are
|
|
188
|
+
# recorded per message (`tps`, `ttft`) and were never shown anywhere.
|
|
189
|
+
#
|
|
190
|
+
# Only rows that actually measured something are averaged — a `system`,
|
|
191
|
+
# `user` or `tool` message stores 0.0 for both, and folding those zeros into
|
|
192
|
+
# the mean would report a throughput no model ever achieved. Each metric
|
|
193
|
+
# carries its own sample count because they are not populated together:
|
|
194
|
+
# `tps` is now written for every dialect (the provider's own rate when it
|
|
195
|
+
# reports one, else derived at commit time — see Message.derive_tps), while
|
|
196
|
+
# `ttft` is only measured on the streaming path.
|
|
197
|
+
#
|
|
198
|
+
# The `NULLIF(tps, 0)` filter stays load-bearing. A zero means "the decode
|
|
199
|
+
# window was too short to measure", not "this turn achieved zero throughput",
|
|
200
|
+
# and folding those into the mean would understate the panel. The explosive
|
|
201
|
+
# case this once guarded against — a window collapsing toward zero and the
|
|
202
|
+
# quotient reaching the thousands — is now excluded at write time by
|
|
203
|
+
# Message::MIN_DECODE_WINDOW, so such a turn stores 0.0 and is skipped here.
|
|
204
|
+
def throughput(period)
|
|
205
|
+
scope = SolidLoop::Message.where("created_at >= ?", interval(period))
|
|
206
|
+
|
|
207
|
+
avg_tps, tps_samples, avg_ttft, ttft_samples = scope.pick(
|
|
208
|
+
Arel.sql("AVG(NULLIF(tps, 0))"),
|
|
209
|
+
Arel.sql("COUNT(*) FILTER (WHERE tps > 0)"),
|
|
210
|
+
Arel.sql("AVG(NULLIF(ttft, 0))"),
|
|
211
|
+
Arel.sql("COUNT(*) FILTER (WHERE ttft > 0)")
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
{
|
|
215
|
+
tps: avg_tps && avg_tps.to_f.round(2),
|
|
216
|
+
tps_samples: tps_samples.to_i,
|
|
217
|
+
ttft: avg_ttft && avg_ttft.to_f.round(3),
|
|
218
|
+
ttft_samples: ttft_samples.to_i
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
|
|
77
222
|
def summary_stats(period)
|
|
78
223
|
failed_counts = failed_split(period)
|
|
79
224
|
|
|
80
225
|
{
|
|
81
|
-
llm_calls: SolidLoop::Event.where(name: "llm_completion").where("created_at
|
|
82
|
-
tool_calls: SolidLoop::Event.where(name: "mcp_tool_call").where("created_at
|
|
83
|
-
cost: SolidLoop::Loop.where("created_at
|
|
226
|
+
llm_calls: SolidLoop::Event.where(name: "llm_completion").where("created_at >= ?", interval(period)).count,
|
|
227
|
+
tool_calls: SolidLoop::Event.where(name: "mcp_tool_call").where("created_at >= ?", interval(period)).count,
|
|
228
|
+
cost: SolidLoop::Loop.where("created_at >= ?", interval(period)).sum(:cost).to_f.round(3),
|
|
84
229
|
failed: failed_counts[:agent] + failed_counts[:infra],
|
|
85
230
|
failed_agent: failed_counts[:agent],
|
|
86
231
|
failed_infra: failed_counts[:infra]
|
|
@@ -93,7 +238,7 @@ module SolidLoop
|
|
|
93
238
|
def failed_split(period)
|
|
94
239
|
messages = SolidLoop::Loop
|
|
95
240
|
.where(status: "failed")
|
|
96
|
-
.where("created_at
|
|
241
|
+
.where("created_at >= ?", interval(period))
|
|
97
242
|
.pluck(:error_message)
|
|
98
243
|
|
|
99
244
|
messages.each_with_object(agent: 0, infra: 0) do |msg, acc|
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
module SolidLoop
|
|
2
2
|
class EventsController < ApplicationController
|
|
3
|
+
# The list only needs enough of an event to say what it was, how long it
|
|
4
|
+
# took and whether it worked. `log` and `request_data` are the two fattest
|
|
5
|
+
# columns on the table (tens of KB per row for a streamed completion), so a
|
|
6
|
+
# 50-row page was hauling megabytes of payload to render a badge. Narrowed
|
|
7
|
+
# AFTER pagination: the paginator still counts on the unrestricted relation,
|
|
8
|
+
# where a multi-column select would turn `count` into COUNT(a, b, ...).
|
|
9
|
+
LIST_COLUMNS = %i[id name loop_id duration response_data created_at].freeze
|
|
10
|
+
|
|
3
11
|
def index
|
|
4
12
|
@pagination = paginate(SolidLoop::Admin::EventsQuery.new(params).call)
|
|
5
|
-
@events = @pagination[:records]
|
|
13
|
+
@events = @pagination[:records].select(*LIST_COLUMNS)
|
|
6
14
|
end
|
|
7
15
|
|
|
8
16
|
def show
|