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
@@ -36,10 +36,28 @@ module SolidLoop
36
36
  all_messages = env.loop.messages.where(is_hidden: false)
37
37
  .order(Arel.sql("COALESCE(conversation_order, id)")).includes(:tool_calls)
38
38
 
39
- # Build tool_call_id -> function_name lookup for tool response messages
40
- tool_call_name_index = all_messages
39
+ # Build tool_call_id -> function_name lookup for tool response messages.
40
+ #
41
+ # Seeded from `tool_calls_raw` before the persisted rows, because a
42
+ # REJECTED call never becomes a `ToolCall` row: when the model sends
43
+ # arguments that are not valid JSON, the gem writes a "Tool call
44
+ # rejected" tool message asking it to resend, and that message's
45
+ # tool_call_id resolves to nothing. Resolving it to nil put `"name":
46
+ # null` on the wire — which OpenAI-compatible servers vary on. vLLM
47
+ # ignored it; llama.cpp rejects the whole request with
48
+ # `type must be string, but is null`, and since the offending message
49
+ # stays in history the loop then fails on every retry, permanently.
50
+ # The name is right there in the raw payload, so use it.
51
+ tool_call_name_index = all_messages.each_with_object({}) do |m, h|
52
+ Array(m.tool_calls_raw).each do |raw|
53
+ id = raw["id"] || raw[:id]
54
+ name = raw.dig("function", "name") || raw.dig(:function, :name)
55
+ h[id] = name if id.present? && name.present?
56
+ end
57
+ end
58
+ all_messages
41
59
  .flat_map { |m| m.tool_calls.sort_by(&:id) }
42
- .each_with_object({}) { |tc, h| h[tc.tool_call_id] = tc.function_name }
60
+ .each { |tc| tool_call_name_index[tc.tool_call_id] = tc.function_name }
43
61
 
44
62
  all_messages.each do |msg|
45
63
  m = { role: msg.role, content: msg.content }
@@ -59,7 +77,9 @@ module SolidLoop
59
77
 
60
78
  if msg.role == "tool"
61
79
  m[:tool_call_id] = msg.tool_call_id
62
- m[:name] = tool_call_name_index[msg.tool_call_id]
80
+ # `name` is optional in the schema; omit it rather than send null.
81
+ name = tool_call_name_index[msg.tool_call_id]
82
+ m[:name] = name if name.present?
63
83
  env.triggering_message = msg
64
84
  elsif msg.role == "user"
65
85
  env.triggering_message = msg
@@ -86,6 +106,20 @@ module SolidLoop
86
106
  stream: !!env.agent.streaming?
87
107
  }
88
108
  env.payload[:tools] = tools if tools.any?
109
+
110
+ # Only when the agent opts in: an absent key lets the provider apply its
111
+ # own default. Set before the merge below so `llm_params` can override it.
112
+ max_tokens = env.agent.max_tokens
113
+ env.payload[:max_tokens] = max_tokens if max_tokens
114
+
115
+ effort = env.agent.reasoning_effort
116
+ env.payload[:reasoning_effort] = effort if effort.present?
117
+
118
+ # Host-supplied keys win: merged last so an agent can add what the gem
119
+ # does not model, or override what it does. Symbolized so a host writing
120
+ # string keys cannot silently produce a duplicate entry alongside ours.
121
+ extra = env.agent.llm_params
122
+ env.payload = env.payload.deep_merge(extra.symbolize_keys) if extra.present?
89
123
  end
90
124
  end
91
125
  end
@@ -73,8 +73,24 @@ module SolidLoop
73
73
  tokens_prompt: usage_data[:tokens_prompt],
74
74
  tokens_completion: usage_data[:tokens_completion],
75
75
  tokens_total: usage_data[:tokens_total],
76
- tokens_prompt_cached: usage_data[:tokens_prompt_cached],
77
- tps: usage_data[:tps],
76
+ # Cached tokens are a SUBSET of the prompt, never more. Clamped at the
77
+ # persistence boundary rather than in one parser because the invariant
78
+ # belongs to the row: a provider that reports the two on different
79
+ # bases (llama.cpp counts `prompt_n` fresh-only) would otherwise store
80
+ # a cache figure larger than the prompt it supposedly came from, and
81
+ # every derived hit-rate would exceed 100%.
82
+ tokens_prompt_cached: usage_data[:tokens_prompt_cached].to_i
83
+ .clamp(0, usage_data[:tokens_prompt].to_i),
84
+ # The provider's own rate wins when it reports one (llama.cpp sends
85
+ # `predicted_per_second`); otherwise derive it here, where the decode
86
+ # window is known. Without this the column stayed 0.0 for every
87
+ # OpenAI/Anthropic/Gemini-dialect turn, so any SQL aggregate over it
88
+ # silently read as "zero throughput" instead of "not measured".
89
+ tps: (usage_data[:tps].to_f.positive? ? usage_data[:tps] : SolidLoop::Message.derive_tps(
90
+ tokens_completion: usage_data[:tokens_completion],
91
+ duration_generation: full_duration,
92
+ ttft: ttft
93
+ )),
78
94
  ttft: ttft,
79
95
  duration_thinking: duration_thinking,
80
96
  duration_generation: full_duration,
@@ -276,7 +292,7 @@ module SolidLoop
276
292
  env.error_headers = safe_response_headers(response)
277
293
  env.error_body = body
278
294
 
279
- error_msg = "HTTP #{status}: #{body}"
295
+ error_msg = SolidLoop::Redaction.redact_credentials("HTTP #{status}: #{body}")
280
296
 
281
297
  is_context_limit = (status == 400) && (
282
298
  body.include?("maximum context length") ||
@@ -19,7 +19,7 @@
19
19
  --sl-bg-color: #0e1621;
20
20
  --sl-sidebar-bg: #17212b;
21
21
  --sl-hover-bg: #202b36;
22
- --sl-border-color: #101921;
22
+ --sl-border-color: #2c3d4d;
23
23
  --sl-text-color: #f5f5f5;
24
24
  --sl-muted-text: #7f91a4;
25
25
  --sl-msg-in: #182533;
@@ -86,6 +86,17 @@
86
86
  overflow: hidden;
87
87
  }
88
88
 
89
+ .sl-subject-link {
90
+ color: var(--sl-muted-text);
91
+ text-decoration: none;
92
+ border-bottom: 1px dotted var(--sl-muted-text);
93
+ }
94
+
95
+ .sl-subject-link:hover {
96
+ color: var(--sl-accent);
97
+ border-bottom-color: var(--sl-accent);
98
+ }
99
+
89
100
  .sl-content-area {
90
101
  flex: 1;
91
102
  overflow-y: auto;
@@ -96,8 +107,8 @@
96
107
  .sl-card {
97
108
  background-color: var(--sl-sidebar-bg);
98
109
  border-radius: var(--sl-radius);
99
- padding: 20px;
100
- margin-bottom: 20px;
110
+ padding: 12px 14px;
111
+ margin-bottom: 10px;
101
112
  border: 1px solid var(--sl-border-color);
102
113
  }
103
114
 
@@ -137,6 +148,62 @@
137
148
  .sl-badge--failed, .sl-badge--error { background-color: rgba(255, 107, 107, 0.2); color: var(--sl-error); }
138
149
  .sl-badge--paused, .sl-badge--frozen { background-color: rgba(133, 77, 14, 0.2); color: #eab308; }
139
150
 
151
+ /* Event families — every event badge used to be the same blue, so
152
+ llm_completion and mcp_initialize looked like the same kind of thing.
153
+ LLM traffic is violet, MCP tool invocations are teal, and the MCP
154
+ lifecycle chatter (initialize / list_tools / list_prompts) is a quiet
155
+ outline so it recedes behind the calls that actually do work. */
156
+ .sl-badge--llm { background-color: rgba(167, 139, 250, 0.18); color: #a78bfa; }
157
+ .sl-badge--mcp { background-color: rgba(45, 212, 191, 0.18); color: #2dd4bf; }
158
+ .sl-badge--mcp-meta {
159
+ background-color: transparent;
160
+ color: var(--sl-muted-text);
161
+ border: 1px solid var(--sl-border-color);
162
+ padding: 3px 7px;
163
+ }
164
+
165
+ /* HTTP / wire outcome */
166
+ .sl-badge--http-ok { background-color: rgba(49, 181, 69, 0.15); color: var(--sl-success); }
167
+ .sl-badge--http-warn { background-color: rgba(241, 196, 15, 0.2); color: var(--sl-pending); }
168
+ .sl-badge--http-error { background-color: rgba(255, 107, 107, 0.2); color: var(--sl-error); }
169
+
170
+ /* Metric cells — durations, tokens, counts. Tabular figures so a column of
171
+ numbers aligns and an outlier is spottable by shape alone; severity
172
+ colours are applied by the duration/idle thresholds in MetricsHelper. */
173
+ .sl-metric {
174
+ font-variant-numeric: tabular-nums;
175
+ font-feature-settings: "tnum";
176
+ white-space: nowrap;
177
+ }
178
+
179
+ .sl-metric--warn { color: var(--sl-pending); }
180
+ .sl-metric--slow { color: var(--sl-error); font-weight: 600; }
181
+ .sl-metric--blank { color: var(--sl-muted-text); }
182
+
183
+ .sl-metric__sub {
184
+ display: block;
185
+ margin-top: 2px;
186
+ font-size: 0.8em;
187
+ color: var(--sl-muted-text);
188
+ font-variant-numeric: tabular-nums;
189
+ }
190
+
191
+ /* Long opaque identifiers (MCP session ids) — truncated, with the full
192
+ value one click away instead of eating half the table width. */
193
+ .sl-copyable {
194
+ display: inline-flex;
195
+ align-items: center;
196
+ gap: 8px;
197
+ }
198
+
199
+ .sl-copyable__text {
200
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
201
+ font-size: 0.85em;
202
+ color: var(--sl-text-color);
203
+ }
204
+
205
+ .sl-copyable__btn { flex-shrink: 0; }
206
+
140
207
  .sl-badge--live {
141
208
  background-color: rgba(255, 107, 107, 0.2);
142
209
  color: var(--sl-error);
@@ -207,10 +274,9 @@
207
274
  .sl-json {
208
275
  font-family: monospace;
209
276
  font-size: 0.85rem;
210
- white-space: pre-wrap;
211
277
  word-break: break-word;
212
278
  background: var(--sl-bg-color);
213
- padding: 10px;
279
+ padding: 8px 10px;
214
280
  border-radius: 6px;
215
281
  border: 1px solid var(--sl-border-color);
216
282
  }
@@ -295,44 +361,89 @@
295
361
 
296
362
  .sl-message__header {
297
363
  display: flex;
298
- justify-content: space-between;
299
364
  align-items: center;
300
- margin-bottom: 10px;
365
+ gap: 8px;
366
+ margin-bottom: 6px;
301
367
  }
302
-
368
+
303
369
  .sl-message__role {
304
370
  text-transform: uppercase;
305
- font-size: 0.85em;
371
+ font-size: 0.75em;
372
+ letter-spacing: 0.06em;
306
373
  color: var(--sl-muted-text);
307
374
  }
308
-
375
+
376
+ .sl-message__model {
377
+ font-weight: normal;
378
+ font-size: 0.75em;
379
+ }
380
+
381
+ /* Pushes the timestamp to the right without a space-between that would
382
+ strand the model badge in the middle of the row. */
309
383
  .sl-message__time {
310
- font-size: 0.8em;
384
+ margin-left: auto;
385
+ font-size: 0.75em;
311
386
  color: var(--sl-muted-text);
312
387
  }
313
-
388
+
389
+ .sl-message__body {
390
+ margin: 0;
391
+ font-family: inherit;
392
+ font-size: inherit;
393
+ }
394
+
314
395
  .sl-message__tools {
315
- margin-top: 10px;
316
- color: var(--sl-pending);
396
+ display: flex;
397
+ flex-wrap: wrap;
398
+ align-items: center;
399
+ gap: 5px;
400
+ margin-top: 8px;
317
401
  }
318
-
402
+
403
+ .sl-message__tools-label {
404
+ text-transform: uppercase;
405
+ font-size: 0.7em;
406
+ letter-spacing: 0.06em;
407
+ color: var(--sl-muted-text);
408
+ }
409
+
410
+ .sl-badge__link { color: inherit; text-decoration: none; }
411
+
412
+ /* Collapsed by default: reasoning is routinely thousands of characters and
413
+ used to push the metrics off-screen on every single turn. */
319
414
  .sl-message__reasoning {
320
- margin-top: 10px;
321
- padding-left: 10px;
322
- border-left: 2px solid var(--sl-border-color);
415
+ margin-top: 8px;
416
+ padding-left: 8px;
417
+ border-left: 2px solid var(--sl-accent);
323
418
  color: var(--sl-muted-text);
324
419
  }
325
-
420
+
421
+ .sl-message__reasoning-toggle {
422
+ cursor: pointer;
423
+ font-size: 0.8em;
424
+ text-transform: uppercase;
425
+ letter-spacing: 0.06em;
426
+ user-select: none;
427
+ }
428
+
429
+ .sl-message__reasoning-toggle:hover { color: var(--sl-text-color); }
430
+ .sl-message__reasoning pre { margin: 6px 0 0; }
431
+
432
+ .sl-message__cached { color: var(--sl-success); }
433
+
326
434
  .sl-message__footer {
327
- margin-top: 10px;
328
- padding-top: 8px;
435
+ margin-top: 8px;
436
+ padding-top: 6px;
329
437
  border-top: 1px solid var(--sl-border-color);
330
438
  display: flex;
331
439
  flex-wrap: wrap;
332
- gap: 15px;
333
- font-size: 0.8em;
440
+ align-items: center;
441
+ gap: 12px;
442
+ font-size: 0.75em;
334
443
  color: var(--sl-muted-text);
335
444
  }
445
+
446
+ .sl-message__footer-item--end { margin-left: auto; }
336
447
 
337
448
  .sl-message__footer-item {
338
449
  display: flex;