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
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 (GitHub-only for now — pin a release tag):
9
+ Add to your Gemfile:
10
10
 
11
11
  ```ruby
12
- gem "solid_loop", github: "ruslan/solid_loop", tag: "v0.0.4"
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
- base_url: "https://api.openai.com/v1",
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 reaper jobs accumulate unrun |
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
- # Top-level alias so `rake coverage:report` works alongside `rake app:coverage:report`
9
- namespace :coverage do
10
- task(:report, [:sort, :filter, :git]) { |t, args| Rake::Task["app:coverage:report"].invoke(*args.to_a) }
11
- end
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] || "hour"
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] || "hour"
14
+ period = normalize_period(params[:period])
9
15
 
10
16
  render json: {
11
17
  period: period,
12
- llm_calls: llm_calls_by_period(period),
13
- tool_calls: tool_calls_by_period(period),
14
- cost: cost_by_period(period),
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 30.days.ago
35
- when "week" then 12.weeks.ago
36
- else 24.hours.ago
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 > ?", interval(period))
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 > ?", interval(period))
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 > ?", interval(period))
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
- # Simulating stacked token counts
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
- # Not fully stacked without separate prompt/completion, but good enough for now
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 > ?", interval(period))
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 > ?", interval(period))
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 > ?", interval(period)).count,
82
- tool_calls: SolidLoop::Event.where(name: "mcp_tool_call").where("created_at > ?", interval(period)).count,
83
- cost: SolidLoop::Loop.where("created_at > ?", interval(period)).sum(:cost).to_f.round(3),
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 > ?", interval(period))
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