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
|
@@ -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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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: #
|
|
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:
|
|
100
|
-
margin-bottom:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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:
|
|
321
|
-
padding-left:
|
|
322
|
-
border-left: 2px solid var(--sl-
|
|
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:
|
|
328
|
-
padding-top:
|
|
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
|
-
|
|
333
|
-
|
|
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;
|