solid_loop 0.0.4
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 +7 -0
- data/CHANGELOG.md +156 -0
- data/CODE_OF_CONDUCT.md +67 -0
- data/CONTRIBUTING.md +82 -0
- data/MIT-LICENSE +21 -0
- data/README.md +483 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
- data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
- data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
- data/app/assets/stylesheets/solid_loop/application.css +15 -0
- data/app/controllers/solid_loop/application_controller.rb +29 -0
- data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
- data/app/controllers/solid_loop/events_controller.rb +12 -0
- data/app/controllers/solid_loop/loops_controller.rb +77 -0
- data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
- data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
- data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
- data/app/controllers/solid_loop/messages_controller.rb +12 -0
- data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
- data/app/helpers/solid_loop/application_helper.rb +125 -0
- data/app/jobs/solid_loop/application_job.rb +13 -0
- data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
- data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
- data/app/jobs/solid_loop/reaper_job.rb +20 -0
- data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
- data/app/models/solid_loop/application_record.rb +5 -0
- data/app/models/solid_loop/base.rb +190 -0
- data/app/models/solid_loop/event.rb +7 -0
- data/app/models/solid_loop/loop.rb +225 -0
- data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
- data/app/models/solid_loop/mcp_session.rb +12 -0
- data/app/models/solid_loop/mcp_tool.rb +26 -0
- data/app/models/solid_loop/message.rb +69 -0
- data/app/models/solid_loop/tool_call.rb +42 -0
- data/app/queries/solid_loop/admin/base_query.rb +23 -0
- data/app/queries/solid_loop/admin/events_query.rb +31 -0
- data/app/queries/solid_loop/admin/loops_query.rb +38 -0
- data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
- data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
- data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
- data/app/queries/solid_loop/admin/messages_query.rb +37 -0
- data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
- data/app/services/solid_loop/adapters/native.rb +170 -0
- data/app/services/solid_loop/dialects/anthropic.rb +160 -0
- data/app/services/solid_loop/dialects/gemini.rb +122 -0
- data/app/services/solid_loop/dialects/open_ai.rb +62 -0
- data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
- data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
- data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
- data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser.rb +19 -0
- data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
- data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
- data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
- data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
- data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
- data/app/services/solid_loop/middlewares/message_building.rb +92 -0
- data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
- data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
- data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
- data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
- data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
- data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
- data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
- data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
- data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
- data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
- data/app/views/layouts/solid_loop/admin.html.erb +416 -0
- data/app/views/layouts/solid_loop/application.html.erb +17 -0
- data/app/views/solid_loop/dashboard/index.html.erb +184 -0
- data/app/views/solid_loop/events/index.html.erb +47 -0
- data/app/views/solid_loop/events/show.html.erb +76 -0
- data/app/views/solid_loop/loops/index.html.erb +83 -0
- data/app/views/solid_loop/loops/show.html.erb +148 -0
- data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
- data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
- data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
- data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
- data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
- data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
- data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
- data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
- data/app/views/solid_loop/messages/_message.html.erb +75 -0
- data/app/views/solid_loop/messages/index.html.erb +80 -0
- data/app/views/solid_loop/messages/show.html.erb +121 -0
- data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
- data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
- data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
- data/config/routes.rb +27 -0
- data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
- data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
- data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
- data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
- data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
- data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
- data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
- data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
- data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
- data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
- data/docs/contributing/coverage.md +64 -0
- data/docs/decisions/durable_attempt_lease.md +364 -0
- data/docs/decisions/mcp-only-tooling.md +135 -0
- data/docs/decisions/mcp-server.md +223 -0
- data/docs/decisions/reasoning_persistence.md +51 -0
- data/docs/decisions/ruby_llm_rejected.md +35 -0
- data/docs/guides/dialects.md +55 -0
- data/docs/guides/llm_middlewares.md +294 -0
- data/docs/guides/mcp_transports.md +374 -0
- data/docs/guides/tool_middlewares.md +148 -0
- data/lib/solid_loop/configuration.rb +175 -0
- data/lib/solid_loop/engine.rb +14 -0
- data/lib/solid_loop/lease_heartbeat.rb +94 -0
- data/lib/solid_loop/lease_renewer.rb +347 -0
- data/lib/solid_loop/llm_metrics.rb +16 -0
- data/lib/solid_loop/mcp/call_context.rb +19 -0
- data/lib/solid_loop/mcp/client_factory.rb +61 -0
- data/lib/solid_loop/mcp/http_transport.rb +52 -0
- data/lib/solid_loop/mcp/principal.rb +82 -0
- data/lib/solid_loop/mcp/result.rb +13 -0
- data/lib/solid_loop/mcp/server.rb +246 -0
- data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
- data/lib/solid_loop/mcp/toolset.rb +347 -0
- data/lib/solid_loop/mcp/transport.rb +20 -0
- data/lib/solid_loop/mcp.rb +25 -0
- data/lib/solid_loop/mcp_client.rb +176 -0
- data/lib/solid_loop/pipeline/builder.rb +38 -0
- data/lib/solid_loop/pipeline/context.rb +61 -0
- data/lib/solid_loop/pipeline/tool_context.rb +53 -0
- data/lib/solid_loop/pipeline.rb +40 -0
- data/lib/solid_loop/reaper.rb +313 -0
- data/lib/solid_loop/version.rb +3 -0
- data/lib/solid_loop.rb +94 -0
- data/lib/tasks/coverage.rake +206 -0
- data/lib/tasks/solid_loop_tasks.rake +4 -0
- metadata +228 -0
data/README.md
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
# SolidLoop
|
|
2
|
+
|
|
3
|
+
Infrastructure layer for running autonomous AI agents as long-lived Rails background processes. Built on `ActiveJob` + `PostgreSQL` with a Rack-style middleware architecture and Model Context Protocol (MCP) tool support.
|
|
4
|
+
|
|
5
|
+
Each agent turn (LLM call or tool execution) is a discrete background job. State lives in the database — not in memory. Crash the process, restart it, the agent continues from where it stopped.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile (GitHub-only for now — pin a release tag):
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "solid_loop", github: "ruslan/solid_loop", tag: "v0.0.4"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then run:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle install
|
|
19
|
+
bin/rails solid_loop:install:migrations
|
|
20
|
+
bin/rails db:migrate
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Rails >= 7.1, PostgreSQL, `faraday`, and `event_stream_parser`.
|
|
24
|
+
|
|
25
|
+
### Job backend requirement
|
|
26
|
+
|
|
27
|
+
SolidLoop requires a **same-database transactional ActiveJob backend** — Solid
|
|
28
|
+
Queue or GoodJob running in the application database. SolidLoop enqueues each
|
|
29
|
+
successor job *inside* the transaction that advances loop state, so state
|
|
30
|
+
advancement and job dispatch commit atomically: a crash leaves neither a state
|
|
31
|
+
change without its job nor a job pointing at rows that never committed.
|
|
32
|
+
(`SolidLoop::ApplicationJob` sets `enqueue_after_transaction_commit = false` so
|
|
33
|
+
the enqueue actually participates in the transaction.)
|
|
34
|
+
|
|
35
|
+
Non-transactional or external backends (Sidekiq/Resque on Redis, SQS, …) do
|
|
36
|
+
**not** get this guarantee: the enqueue escapes the transaction, reintroducing
|
|
37
|
+
a crash window between state commit and dispatch — and jobs may even run before
|
|
38
|
+
the enclosing transaction commits. They would need a transactional-outbox
|
|
39
|
+
layer, which SolidLoop does not provide.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
Define an agent class inheriting from `SolidLoop::Base`:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class MyAgent < SolidLoop::Base
|
|
47
|
+
def system_prompt
|
|
48
|
+
"You are a helpful assistant."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def llm_provider
|
|
52
|
+
{
|
|
53
|
+
base_url: "https://api.openai.com/v1",
|
|
54
|
+
api_token: ENV["OPENAI_API_KEY"],
|
|
55
|
+
model: "gpt-4o", # used in the JSON payload (all dialects)
|
|
56
|
+
# llm_model_name: "...", # used for URL construction in the Gemini dialect
|
|
57
|
+
read_timeout: 120
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Tools (MCP-only)
|
|
64
|
+
|
|
65
|
+
Every tool in SolidLoop is an MCP tool. MCP is the *interface*; where and how a
|
|
66
|
+
tool server runs is a *transport* concern. An agent declares its tool servers
|
|
67
|
+
via `mcps` — `name:` is required in every entry:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
class MyAgent < SolidLoop::Base
|
|
71
|
+
def mcps
|
|
72
|
+
[
|
|
73
|
+
# 1. Remote MCP server over Streamable HTTP
|
|
74
|
+
{ name: :main,
|
|
75
|
+
url: ENV["MCP_URL"],
|
|
76
|
+
api_token: ENV["MCP_TOKEN"],
|
|
77
|
+
tools: %w[list_files read_file], # optional whitelist
|
|
78
|
+
required_tools: %w[list_files] }, # optional fail-fast validation
|
|
79
|
+
|
|
80
|
+
# 2. In-process toolset — the class below IS the MCP server, no HTTP
|
|
81
|
+
{ name: :search, toolset: SearchTools },
|
|
82
|
+
|
|
83
|
+
# 3. Child-process MCP server over stdio (spawn-per-call, stateless only)
|
|
84
|
+
{ name: :fs,
|
|
85
|
+
transport: SolidLoop::Mcp::StdioTransport.new(
|
|
86
|
+
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/data"],
|
|
87
|
+
timeout: 30
|
|
88
|
+
) }
|
|
89
|
+
]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Toolsets: in-process tools
|
|
95
|
+
|
|
96
|
+
A toolset groups the tools of one domain into a logical MCP server that runs
|
|
97
|
+
in-process, on the caller's thread, with full access to your app:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
class SearchTools < SolidLoop::Mcp::Toolset
|
|
101
|
+
server_name "search"
|
|
102
|
+
|
|
103
|
+
tool "web_search",
|
|
104
|
+
description: "Search the web and return the top results",
|
|
105
|
+
input_schema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: { query: { type: "string" } },
|
|
108
|
+
required: %w[query]
|
|
109
|
+
} do |args, ctx|
|
|
110
|
+
Search::Web.call(loop: ctx.loop, **args.symbolize_keys)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
tool "fetch_url",
|
|
114
|
+
description: "Fetch a URL and return a text summary",
|
|
115
|
+
input_schema: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: { url: { type: "string" } },
|
|
118
|
+
required: %w[url]
|
|
119
|
+
} do |args, ctx|
|
|
120
|
+
Search::Fetch.call(loop: ctx.loop, **args.symbolize_keys).to_json
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The block receives string-keyed `args` (exactly as they would arrive over HTTP)
|
|
126
|
+
and a `CallContext` (`ctx.agent`, `ctx.loop`). A String return value becomes
|
|
127
|
+
text content; a Hash is also exposed as `structuredContent`; raising produces
|
|
128
|
+
an `isError` tool result the model can self-correct on.
|
|
129
|
+
|
|
130
|
+
Tool names must be loop-unique across servers — add `prefix: "search"` to an
|
|
131
|
+
entry to advertise its tools as `search__*`; collisions without a prefix fail
|
|
132
|
+
loudly at session initialization. Details — transports, custom adapters, the
|
|
133
|
+
conformance suite, `session_recovery:` — in
|
|
134
|
+
[docs/guides/mcp_transports.md](docs/guides/mcp_transports.md); the decision and
|
|
135
|
+
the migration guide from 0.0.2 native tools in
|
|
136
|
+
[docs/decisions/mcp-only-tooling.md](docs/decisions/mcp-only-tooling.md).
|
|
137
|
+
|
|
138
|
+
### Serving toolsets over HTTP (MCP server)
|
|
139
|
+
|
|
140
|
+
Define a toolset once — the in-process agent consumes it locally, and external
|
|
141
|
+
MCP clients (Claude Code, other apps) consume the same tools remotely, with
|
|
142
|
+
shared schemas, wire logs and admin. Mount each toolset you want to expose:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
# config/routes.rb
|
|
146
|
+
mount SolidLoop::Mcp.server(SearchTools, auth: McpAuth.new) => "/mcp/search"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
An unmounted toolset is not exposed. The endpoint is fail-closed: `auth:` is
|
|
150
|
+
required and is any object responding to
|
|
151
|
+
`call(token, request) -> principal | nil` — return the caller's identity (an
|
|
152
|
+
API-client record, a user, a symbol) or `nil` for a 401. Inside tool handlers
|
|
153
|
+
external calls arrive with `ctx.principal` set and `ctx.agent` / `ctx.loop`
|
|
154
|
+
`nil`. Inbound sessions and per-request wire logs appear in the admin UI under
|
|
155
|
+
**Inbound MCP**. Details in
|
|
156
|
+
[docs/guides/mcp_transports.md](docs/guides/mcp_transports.md); the design in
|
|
157
|
+
[docs/decisions/mcp-server.md](docs/decisions/mcp-server.md).
|
|
158
|
+
|
|
159
|
+
### Customizing the middleware pipeline per agent
|
|
160
|
+
|
|
161
|
+
Override `configure_llm_middlewares` or `configure_tool_middlewares` to inject middleware into the pipeline for a specific agent only:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
class MyAgent < SolidLoop::Base
|
|
165
|
+
# Runs before each LlmCompletionJob — insert/remove LLM pipeline layers here.
|
|
166
|
+
def configure_llm_middlewares(builder)
|
|
167
|
+
builder.insert_before(
|
|
168
|
+
SolidLoop::Middlewares::ResponseParsing,
|
|
169
|
+
SolidLoop::Middlewares::ToolCallXmlParser # enable XML tool-call parsing for this agent
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Runs before each ToolExecutionJob — insert/remove tool pipeline layers here.
|
|
174
|
+
def configure_tool_middlewares(builder)
|
|
175
|
+
builder.insert_after(SolidLoop::ToolMiddlewares::ToolExecution, MyAuditMiddleware)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Global pipeline changes (applying to all agents) go in an initializer instead — see `docs/guides/llm_middlewares.md`.
|
|
181
|
+
|
|
182
|
+
Start a loop (creates DB records and enqueues `LlmCompletionJob`):
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
loop_record = MyAgent.start!(
|
|
186
|
+
subject: current_user,
|
|
187
|
+
message: "Summarize this week's engineering updates."
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Pause and resume:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
agent = MyAgent.new(loop_record)
|
|
195
|
+
agent.pause!
|
|
196
|
+
agent.resume!
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Loop status lifecycle: `init` → `queued` → `running` → `completed` / `failed` / `paused`.
|
|
200
|
+
|
|
201
|
+
## Admin UI
|
|
202
|
+
|
|
203
|
+
SolidLoop ships with a mountable admin UI for inspecting loops, messages, tool calls, MCP sessions/tools, events, and a lightweight dashboard.
|
|
204
|
+
|
|
205
|
+
Mount the engine in your app routes:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
# config/routes.rb
|
|
209
|
+
mount SolidLoop::Engine => "/solid_loop"
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The admin UI is intentionally unauthenticated inside the gem. Protect the mounted path in the host application the same way you would protect Sidekiq Web or GoodJob admin.
|
|
213
|
+
|
|
214
|
+
Features include:
|
|
215
|
+
|
|
216
|
+
- loop/message/tool/event browsing
|
|
217
|
+
- loop control actions: observe, freeze, unfreeze, force stop
|
|
218
|
+
- live loop message updates with Turbo when observe mode is enabled
|
|
219
|
+
|
|
220
|
+
### Live observation requires `turbo-rails`
|
|
221
|
+
|
|
222
|
+
The "observe" mode broadcasts new messages over Turbo Streams, so it depends on
|
|
223
|
+
[`turbo-rails`](https://github.com/hotwired/turbo-rails) being present in the host
|
|
224
|
+
application. `turbo-rails` is **not** a hard dependency of the gem — everything
|
|
225
|
+
else works without it.
|
|
226
|
+
|
|
227
|
+
If a loop has observe mode enabled but `turbo-rails` is not available, SolidLoop
|
|
228
|
+
skips the live broadcast and logs a warning (`Rails.logger.warn`) instead of
|
|
229
|
+
raising. To use live observation, add `gem "turbo-rails"` to your host app.
|
|
230
|
+
|
|
231
|
+
## Streaming
|
|
232
|
+
|
|
233
|
+
Each agent controls streaming via the `streaming?` method. This is a conscious architectural decision, not a default setting.
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
class MyAgent
|
|
237
|
+
def streaming?
|
|
238
|
+
true # or false
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### When to use streaming (`true`)
|
|
244
|
+
|
|
245
|
+
| Scenario | Reasoning |
|
|
246
|
+
|----------|-----------|
|
|
247
|
+
| Live chat with a user | Tokens appear as they arrive — no frozen screen for 5–30s |
|
|
248
|
+
| Long generations (>5s) with a frontend | Perceived latency drops dramatically |
|
|
249
|
+
| Slow local model (Ollama, vLLM, llama.cpp) | Each token resets the read timeout — no need for a 2-hour timeout on the full generation |
|
|
250
|
+
| Measuring TTFT | Time-to-first-token is only observable in streaming mode |
|
|
251
|
+
| Cancellation | `CancellationError` only fires mid-stream |
|
|
252
|
+
|
|
253
|
+
### When to use non-streaming (`false`)
|
|
254
|
+
|
|
255
|
+
| Scenario | Reasoning |
|
|
256
|
+
|----------|-----------|
|
|
257
|
+
| Background agents with no UI | No one is watching — streaming adds complexity for no benefit |
|
|
258
|
+
| Batch processing / ETL / cron jobs | You need throughput, not perceived latency |
|
|
259
|
+
| Structured output / JSON mode | Simpler to parse a complete response than partial chunks |
|
|
260
|
+
| Production without a performance dashboard | Removes SSE parsing overhead; simpler failure modes |
|
|
261
|
+
| Short generations (<2s) | Latency difference is imperceptible to users |
|
|
262
|
+
|
|
263
|
+
**Rule of thumb:** if a human is watching a screen, use streaming. If it's a background job, don't.
|
|
264
|
+
|
|
265
|
+
### Slow local models: streaming wins
|
|
266
|
+
|
|
267
|
+
With a slow CPU/GPU model, streaming is almost always the right call even for background agents:
|
|
268
|
+
|
|
269
|
+
- `read_timeout` is counted between chunks, not over the full response. Each token resets the timer, so a 120s timeout is safe even for a 30-minute generation.
|
|
270
|
+
- Without streaming, `read_timeout` must cover the entire generation — potentially hours. Any network hiccup silently kills the job.
|
|
271
|
+
|
|
272
|
+
The one exception: if the model runs in a completely isolated environment with no network risk and no UI, non-streaming with a generous timeout is simpler.
|
|
273
|
+
|
|
274
|
+
### Anthropic and Gemini streaming
|
|
275
|
+
|
|
276
|
+
SolidLoop's built-in streaming parser (`SseStreamAggregator`) handles OpenAI-compatible SSE format. **Anthropic and Gemini use different wire formats** — their native streaming will not parse correctly with the `anthropic` or `gemini` dialects today.
|
|
277
|
+
|
|
278
|
+
**Solution: use an LLM proxy** that presents any provider as an OpenAI-compatible endpoint, then use the `open_ai` dialect with `streaming: true`:
|
|
279
|
+
|
|
280
|
+
| Proxy | Notes |
|
|
281
|
+
|-------|-------|
|
|
282
|
+
| [LiteLLM](https://github.com/BerriAI/litellm) | Open-source, self-hosted. Translates 100+ providers to OpenAI format. Recommended for production. |
|
|
283
|
+
| [OpenRouter](https://openrouter.ai) | Hosted service. Single API key, pay-per-token across providers. Good for prototyping. |
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
# Anthropic Claude via LiteLLM proxy — full streaming support
|
|
287
|
+
class MyAgent < SolidLoop::Base
|
|
288
|
+
def llm_dialect_name = :open_ai # proxy speaks OpenAI SSE
|
|
289
|
+
|
|
290
|
+
def streaming? = true
|
|
291
|
+
|
|
292
|
+
def llm_provider
|
|
293
|
+
{
|
|
294
|
+
base_url: "http://localhost:4000", # LiteLLM proxy
|
|
295
|
+
api_token: ENV["LITELLM_API_KEY"],
|
|
296
|
+
model: "anthropic/claude-opus-4-5"
|
|
297
|
+
}
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
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
|
+
|
|
304
|
+
## Read Timeout
|
|
305
|
+
|
|
306
|
+
`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.
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
def llm_provider
|
|
310
|
+
{
|
|
311
|
+
base_url: "...",
|
|
312
|
+
api_token: "...",
|
|
313
|
+
model: "...",
|
|
314
|
+
read_timeout: 300 # seconds
|
|
315
|
+
}
|
|
316
|
+
end
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**How it works:** the timeout counts silence between consecutive reads from the socket — not total request duration. During streaming, each incoming token resets the timer. The dangerous window is **prefill** (before the first token), when the model is silent.
|
|
320
|
+
|
|
321
|
+
### Choosing the right value
|
|
322
|
+
|
|
323
|
+
| Scenario | Recommended |
|
|
324
|
+
|----------|-------------|
|
|
325
|
+
| Cloud provider (OpenAI, Gemini, etc.) with streaming | `60–120s` — prefill is fast, network is reliable |
|
|
326
|
+
| Cloud provider without streaming | `120–300s` — full response must arrive in one read |
|
|
327
|
+
| Local model with streaming | `600–1800s` — prefill on CPU can take minutes |
|
|
328
|
+
| Local model without streaming | `3600s` or more — entire generation (prefill + decode) must complete before the first byte arrives |
|
|
329
|
+
|
|
330
|
+
**Rule of thumb:** with streaming enabled, set the timeout to cover your worst-case prefill time. Without streaming, it must cover the entire generation — for a large local model producing thousands of tokens on CPU, that can be hours.
|
|
331
|
+
|
|
332
|
+
## Durability: lease + reaper
|
|
333
|
+
|
|
334
|
+
Every LLM turn is claimed with an **execution token** (the generation fence) and a **lease** (`lease_expires_at`). The lease is derived *per claim* from the agent's `read_timeout` plus a margin. When a worker dies mid-turn, the lease expires and a periodic **reaper** heals the loop.
|
|
335
|
+
|
|
336
|
+
### The LLM heartbeat (why the lease alone isn't enough)
|
|
337
|
+
|
|
338
|
+
`read_timeout` is an **inactivity** timeout — the silence between two socket reads — **not** a bound on total request duration (see [Read Timeout](#read-timeout) above). A healthy model can stream for tens of minutes with every inter-chunk gap under `read_timeout`, and pre-call MCP session init is likewise unbounded by it. So a static `read_timeout + margin` lease would expire on a **live, healthy** worker mid-turn and be falsely reclaimed.
|
|
339
|
+
|
|
340
|
+
To prevent that, a lightweight **heartbeat** renews the lease for the entire claimed LLM job — MCP init + the whole stream (including a silent prefill) + finalization — on a dedicated DB connection, at ~`lease/4` cadence. Each renew is conditional on the loop still being `running` with the *same* execution token, so it can never renew a lease it no longer owns; if it can't (the generation rotated away or the reaper reclaimed the turn), the worker raises `SolidLoop::LostLease` before writing anything and yields the turn. Correctness ultimately rests on the commit-time fence check under the row lock; the heartbeat only keeps a healthy turn from being *falsely* reclaimed.
|
|
341
|
+
|
|
342
|
+
### Tool leases
|
|
343
|
+
|
|
344
|
+
Each unresolved tool call carries its own **per-tool lease** (`lease_token` / `leased_until`) so the reaper can reclaim one dead parallel tool without fencing its healthy siblings (parallel tools are the shipped default). Unlike the LLM path there is **no tool heartbeat** — a tool invocation is a single bounded client call, so the per-claim lease derivation suffices:
|
|
345
|
+
|
|
346
|
+
- The tool lease is derived as `max(default_tool_timeout, slowest resolved HTTP MCP client timeout) + lease_margin`. An HTTP MCP client's timeout (`mcp_config[:timeout] || 60`, operator-settable) is folded in, so a *healthy* long-running HTTP tool is never reclaimed mid-call.
|
|
347
|
+
- **Residual — in-process / custom-transport tools:** these expose no client timeout to derive from, so `default_tool_timeout` is their ceiling. An in-process tool that legitimately runs **longer** than `default_tool_timeout` MAY be reclaimed → a *duplicate invocation*. The both-token fence keeps SolidLoop's own record clean — exactly **one canonical result** is committed regardless — but the tool's **external effect** is *at-least-once*: the second invocation's side effect DOES run and will repeat unless the handler atomically deduplicates on the stable idempotency key (`solid_loop:tool_call:<id>`, delivered in-process via `CallContext#idempotency_key` and on the wire as MCP `_meta`). So **set `default_tool_timeout` above your slowest in-process tool** to avoid the duplicate entirely, and make irreversible handlers dedupe on that key.
|
|
348
|
+
|
|
349
|
+
Install the reaper recurrence with whatever scheduler you already run — the gem does not own a scheduler. `SolidLoop::ReaperJob` is a plain entry point for `SolidLoop.reap!`:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
# good_job (config/application.rb or an initializer)
|
|
353
|
+
config.good_job.enable_cron = true
|
|
354
|
+
config.good_job.cron = {
|
|
355
|
+
solid_loop_reaper: { cron: "* * * * *", class: "SolidLoop::ReaperJob" }
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`SolidLoop.reap!` is idempotent and safe to run concurrently (each case re-checks its condition under a row lock, so two racing reapers resolve to one). It re-enqueues dead LLM turns (expired lease), reclaims stalled tool calls, repairs dropped enqueues, re-enqueues loops stuck `queued` past a threshold, and sweeps orphaned `processing` shells off paused/terminal loops. `SolidLoop.last_reaped_at` exposes the timestamp of the last successful reap as a liveness health signal (**process-local** — see the known limitations below).
|
|
360
|
+
|
|
361
|
+
### Required queues (allowlisting hosts must consume ALL THREE)
|
|
362
|
+
|
|
363
|
+
SolidLoop routes its jobs onto three fixed queues. A host that runs a worker with an explicit queue **allowlist** (rather than "all queues") MUST consume every one of them, or work silently stalls:
|
|
364
|
+
|
|
365
|
+
| Queue | Jobs | Consequence if not consumed |
|
|
366
|
+
| --- | --- | --- |
|
|
367
|
+
| `solid_loop_llm` | `LlmCompletionJob` (LLM turns) | loops never advance |
|
|
368
|
+
| `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 |
|
|
370
|
+
|
|
371
|
+
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
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
# config/environments/production.rb (GoodJob example)
|
|
375
|
+
config.good_job.queues = "solid_loop_llm,solid_loop_tool,solid_loop_maintenance"
|
|
376
|
+
# (or simply "*" to consume every queue)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Tunables (all optional):
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
SolidLoop.configure do |c|
|
|
383
|
+
c.config.lease_margin = 60 # seconds added on top of read_timeout / tool timeout when deriving a lease (must be > 0)
|
|
384
|
+
c.config.queued_reap_threshold = 300 # a loop queued longer than this is re-enqueued (lost queue row)
|
|
385
|
+
c.config.default_read_timeout = 600 # fallback read_timeout when an agent's llm_provider omits one
|
|
386
|
+
c.config.default_tool_timeout = 600 # ceiling for an in-process/custom tool's lease (raise above your slowest such tool)
|
|
387
|
+
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
|
+
c.config.default_max_duration = 7200 # fallback max_duration (s) sizing the leak ceiling when a registration omits one (mirrors SolidLoop::Base#max_duration)
|
|
389
|
+
end
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
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
|
+
|
|
394
|
+
### The LLM lease renewer (one shared background thread)
|
|
395
|
+
|
|
396
|
+
The heartbeat is a **single process-wide background service** (`SolidLoop::LeaseRenewer`), not one thread per turn. Each in-flight `LlmCompletionJob` registers its `(loop_id, execution_token, lease_duration)` on start and deregisters when the turn ends; the renewer batch-renews every registered lease each tick from **one long-lived DB connection**. This keeps renewal's connection demand **O(1)** — a single checkout for all in-flight turns — regardless of worker concurrency. (A per-turn renewer thread each checking out its own connection would livelock at full pool occupancy: every heartbeat would block waiting for a connection and treat the exhaustion as a lost lease.)
|
|
397
|
+
|
|
398
|
+
Because the renewer holds one connection for the process lifetime, **size your DB pool as `worker_concurrency + 1`** so a full set of busy workers plus the renewer never contends. The renewer draws from the same pool as the workers (so it shares their exact view of the data).
|
|
399
|
+
|
|
400
|
+
Leak safety does not depend on the job's `ensure` ever running: each registration carries a hard ceiling tied to the **owning agent's own legal turn bound** — `max_duration + lease_leak_grace` (default `2h + 5min`). Once a registration exceeds it — only possible if a turn somehow never deregistered — the renewer drops it and stops renewing, so its lease lapses and the reaper reclaims the loop. Crucially, a **legitimate long turn is never dropped**: the agent already caps a legal turn at `max_duration` (its own contract), so any turn within that bound finishes before the ceiling. (Tying the ceiling to `lease_duration × K` instead would be wrong — a healthy turn stays registered for its whole life, so any legitimate stream longer than K lease-widths, e.g. a small `read_timeout` with steady chunks, would be dropped and reaper-reclaimed mid-flight, and no long turn could ever complete.)
|
|
401
|
+
|
|
402
|
+
### Known limitations (0.0.x)
|
|
403
|
+
|
|
404
|
+
The durability guarantees above are the shipped contract; the following are **accepted, documented boundaries** for the 0.0.x line (recovery is at-least-once, and canonical DB effects are deduped by the `executed_at` guard + stable ToolCall key):
|
|
405
|
+
|
|
406
|
+
- **(a) Continuation failure after a tool checkpoint surfaces late.** A continuation failure *immediately after* a tool checkpoint may surface only after the reaper redelivers the checkpointed call — the tool body is **not** repeated; only the dropped continuation is retried.
|
|
407
|
+
- **(b) Slow in-process/custom tools may duplicate their external effect.** An in-process or custom-transport tool that runs longer than `default_tool_timeout` may be reclaimed and re-invoked, so its **external** side effect can run more than once (the canonical DB result is still exactly one). Mitigated by the stable `solid_loop:tool_call:<id>` idempotency key; raise `default_tool_timeout` above your slowest such tool.
|
|
408
|
+
- **(c) Tool-lease derivation bounds configured HTTP MCP calls only.** Cumulative setup/recovery time and opaque custom transports can exceed the derived tool lease and be redelivered under the at-least-once contract.
|
|
409
|
+
- **(d) Reaper case-2 continuation can enqueue duplicates.** A dropped-continuation re-enqueue may deliver a duplicate `ToolExecutionJob`; canonical effects are deduped (idempotent `response_message` check), so it is harmless.
|
|
410
|
+
- **(e) `SolidLoop.last_reaped_at` is process-local, not fleet-wide.** It reflects only the reap runs of the current process. For fleet liveness, alert on your scheduler/backend metrics (e.g. GoodJob cron execution), not this value.
|
|
411
|
+
- **(f) Mixed old/new workers across the lease upgrade are unsupported.** Drain jobs or deploy atomically — do not run pre-lease and post-lease workers against the same database simultaneously.
|
|
412
|
+
- **(g) Lease timestamps are Rails UTC `:datetime`.** Do not reinterpret `lease_expires_at` / `leased_until` as local time.
|
|
413
|
+
- **(h) Reaper scans/indexes are adequate for 0.0.x, not scale-tuned.** The partial indexes and per-loop scans are correct and cheap at current scale; large-fleet tuning is an additive later change.
|
|
414
|
+
- **(i) Middleware that REPLACES a canonical persistence finalizer forfeits the guarantees.** Custom middleware that *replaces* (rather than wraps) a canonical finalizer (`ResponseParsing` / `ToolExecution` / `ResponseCreation`) must reproduce the heartbeat / both-token / row-lock fences itself, or it loses the durability guarantees.
|
|
415
|
+
- **(j) A hand-rolled `paused→running` resume MUST revoke the gated tool_call's lease before re-enqueuing.** If your host implements its own resume that re-enqueues a `ToolExecutionJob` for an unexecuted tool_call, it must FIRST NULL that call's `lease_token`/`leased_until` (as well as rotate a fresh `execution_token`) — otherwise the re-enqueued job's claim CAS matches zero rows and no-ops **silently**, stranding the loop until the stale lease's TTL expires and the reaper reclaims it. `SolidLoop::Base#resume!` does exactly this (revoke-then-enqueue in one locked txn) and is the pattern to mirror. The claim no-op now logs a `ToolExecutionJob: tool lease claim lost …` warning so the strand is diagnosable.
|
|
416
|
+
|
|
417
|
+
> **Note (streaming UI):** mid-stream partials are stored on a *hidden* message row until the turn commits, so a reclaimed/cancelled/paused partial never re-enters history. pause / force-stop / freeze (and a reaper backstop) mark any orphaned `processing` shell failed+hidden. To render live streaming in a host view, include hidden `processing` assistant shells in your live query (they are still broadcast).
|
|
418
|
+
|
|
419
|
+
## Testing
|
|
420
|
+
|
|
421
|
+
The test suite uses RSpec, WebMock, and FactoryBot against a PostgreSQL dummy app. Tests run against Rails 7.1, 7.2, and 8.1 via [appraisals](https://github.com/thoughtbot/appraisal).
|
|
422
|
+
|
|
423
|
+
### Setup
|
|
424
|
+
|
|
425
|
+
```bash
|
|
426
|
+
bundle install
|
|
427
|
+
bundle exec appraisal install
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Create the test database (uses `DB_HOST`, `DB_USERNAME`, `DB_PASSWORD` env vars, defaulting to `localhost` / `postgres` / `""`):
|
|
431
|
+
|
|
432
|
+
```bash
|
|
433
|
+
bundle exec rails db:create db:migrate RAILS_ENV=test
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### Running tests
|
|
437
|
+
|
|
438
|
+
```bash
|
|
439
|
+
# Against the default Gemfile (Rails 8.1)
|
|
440
|
+
bundle exec rspec
|
|
441
|
+
|
|
442
|
+
# Against a specific Rails version
|
|
443
|
+
bundle exec appraisal rails-7-1 rspec
|
|
444
|
+
bundle exec appraisal rails-7-2 rspec
|
|
445
|
+
bundle exec appraisal rails-8-1 rspec
|
|
446
|
+
|
|
447
|
+
# All versions
|
|
448
|
+
bundle exec appraisal rspec
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### Test infrastructure
|
|
452
|
+
|
|
453
|
+
**`StreamingLlmEmulator`** — stubs the LLM HTTP endpoint (OpenAI-compatible). Matches requests by the last message's content and returns pre-configured streaming (SSE) or non-streaming responses. Records all requests for assertion.
|
|
454
|
+
|
|
455
|
+
```ruby
|
|
456
|
+
let(:emulator) { StreamingLlmEmulator.new("http://llm.local", "my-token") }
|
|
457
|
+
|
|
458
|
+
before { emulator.set_answer(/hello/i, { role: "assistant", content: "Hi!" }) }
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
**`McpEmulator`** — stubs an MCP server. Handles `initialize` / `tools/list` / `tools/call` JSON-RPC methods with session validation.
|
|
462
|
+
|
|
463
|
+
```ruby
|
|
464
|
+
let(:mcp) { McpEmulator.new("http://mcp.local") }
|
|
465
|
+
|
|
466
|
+
before { mcp.set_tool_result("my_tool", "tool output") }
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**`TestAgent`** — a minimal `SolidLoop::Base` subclass defined in the dummy app. Configure it via `loop_record.state`:
|
|
470
|
+
|
|
471
|
+
```ruby
|
|
472
|
+
loop_record = TestAgent.start!(
|
|
473
|
+
subject: nil,
|
|
474
|
+
message: "Hello",
|
|
475
|
+
base_url: "http://llm.local",
|
|
476
|
+
api_token: "my-token",
|
|
477
|
+
model: "test-model",
|
|
478
|
+
streaming: false
|
|
479
|
+
)
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
## License
|
|
483
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
|
|
3
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
4
|
+
load "rails/tasks/engine.rake"
|
|
5
|
+
|
|
6
|
+
require "bundler/gem_tasks"
|
|
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
|