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.
Files changed (137) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +156 -0
  3. data/CODE_OF_CONDUCT.md +67 -0
  4. data/CONTRIBUTING.md +82 -0
  5. data/MIT-LICENSE +21 -0
  6. data/README.md +483 -0
  7. data/Rakefile +11 -0
  8. data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
  9. data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
  10. data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
  11. data/app/assets/stylesheets/solid_loop/application.css +15 -0
  12. data/app/controllers/solid_loop/application_controller.rb +29 -0
  13. data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
  14. data/app/controllers/solid_loop/events_controller.rb +12 -0
  15. data/app/controllers/solid_loop/loops_controller.rb +77 -0
  16. data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
  17. data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
  18. data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
  19. data/app/controllers/solid_loop/messages_controller.rb +12 -0
  20. data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
  21. data/app/helpers/solid_loop/application_helper.rb +125 -0
  22. data/app/jobs/solid_loop/application_job.rb +13 -0
  23. data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
  24. data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
  25. data/app/jobs/solid_loop/reaper_job.rb +20 -0
  26. data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
  27. data/app/models/solid_loop/application_record.rb +5 -0
  28. data/app/models/solid_loop/base.rb +190 -0
  29. data/app/models/solid_loop/event.rb +7 -0
  30. data/app/models/solid_loop/loop.rb +225 -0
  31. data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
  32. data/app/models/solid_loop/mcp_session.rb +12 -0
  33. data/app/models/solid_loop/mcp_tool.rb +26 -0
  34. data/app/models/solid_loop/message.rb +69 -0
  35. data/app/models/solid_loop/tool_call.rb +42 -0
  36. data/app/queries/solid_loop/admin/base_query.rb +23 -0
  37. data/app/queries/solid_loop/admin/events_query.rb +31 -0
  38. data/app/queries/solid_loop/admin/loops_query.rb +38 -0
  39. data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
  40. data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
  41. data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
  42. data/app/queries/solid_loop/admin/messages_query.rb +37 -0
  43. data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
  44. data/app/services/solid_loop/adapters/native.rb +170 -0
  45. data/app/services/solid_loop/dialects/anthropic.rb +160 -0
  46. data/app/services/solid_loop/dialects/gemini.rb +122 -0
  47. data/app/services/solid_loop/dialects/open_ai.rb +62 -0
  48. data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
  49. data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
  50. data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
  51. data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
  52. data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
  53. data/app/services/solid_loop/llm_usage_parser.rb +19 -0
  54. data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
  55. data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
  56. data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
  57. data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
  58. data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
  59. data/app/services/solid_loop/middlewares/message_building.rb +92 -0
  60. data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
  61. data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
  62. data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
  63. data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
  64. data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
  65. data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
  66. data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
  67. data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
  68. data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
  69. data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
  70. data/app/views/layouts/solid_loop/admin.html.erb +416 -0
  71. data/app/views/layouts/solid_loop/application.html.erb +17 -0
  72. data/app/views/solid_loop/dashboard/index.html.erb +184 -0
  73. data/app/views/solid_loop/events/index.html.erb +47 -0
  74. data/app/views/solid_loop/events/show.html.erb +76 -0
  75. data/app/views/solid_loop/loops/index.html.erb +83 -0
  76. data/app/views/solid_loop/loops/show.html.erb +148 -0
  77. data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
  78. data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
  79. data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
  80. data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
  81. data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
  82. data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
  83. data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
  84. data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
  85. data/app/views/solid_loop/messages/_message.html.erb +75 -0
  86. data/app/views/solid_loop/messages/index.html.erb +80 -0
  87. data/app/views/solid_loop/messages/show.html.erb +121 -0
  88. data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
  89. data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
  90. data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
  91. data/config/routes.rb +27 -0
  92. data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
  93. data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
  94. data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
  95. data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
  96. data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
  97. data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
  98. data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
  99. data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
  100. data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
  101. data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
  102. data/docs/contributing/coverage.md +64 -0
  103. data/docs/decisions/durable_attempt_lease.md +364 -0
  104. data/docs/decisions/mcp-only-tooling.md +135 -0
  105. data/docs/decisions/mcp-server.md +223 -0
  106. data/docs/decisions/reasoning_persistence.md +51 -0
  107. data/docs/decisions/ruby_llm_rejected.md +35 -0
  108. data/docs/guides/dialects.md +55 -0
  109. data/docs/guides/llm_middlewares.md +294 -0
  110. data/docs/guides/mcp_transports.md +374 -0
  111. data/docs/guides/tool_middlewares.md +148 -0
  112. data/lib/solid_loop/configuration.rb +175 -0
  113. data/lib/solid_loop/engine.rb +14 -0
  114. data/lib/solid_loop/lease_heartbeat.rb +94 -0
  115. data/lib/solid_loop/lease_renewer.rb +347 -0
  116. data/lib/solid_loop/llm_metrics.rb +16 -0
  117. data/lib/solid_loop/mcp/call_context.rb +19 -0
  118. data/lib/solid_loop/mcp/client_factory.rb +61 -0
  119. data/lib/solid_loop/mcp/http_transport.rb +52 -0
  120. data/lib/solid_loop/mcp/principal.rb +82 -0
  121. data/lib/solid_loop/mcp/result.rb +13 -0
  122. data/lib/solid_loop/mcp/server.rb +246 -0
  123. data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
  124. data/lib/solid_loop/mcp/toolset.rb +347 -0
  125. data/lib/solid_loop/mcp/transport.rb +20 -0
  126. data/lib/solid_loop/mcp.rb +25 -0
  127. data/lib/solid_loop/mcp_client.rb +176 -0
  128. data/lib/solid_loop/pipeline/builder.rb +38 -0
  129. data/lib/solid_loop/pipeline/context.rb +61 -0
  130. data/lib/solid_loop/pipeline/tool_context.rb +53 -0
  131. data/lib/solid_loop/pipeline.rb +40 -0
  132. data/lib/solid_loop/reaper.rb +313 -0
  133. data/lib/solid_loop/version.rb +3 -0
  134. data/lib/solid_loop.rb +94 -0
  135. data/lib/tasks/coverage.rake +206 -0
  136. data/lib/tasks/solid_loop_tasks.rake +4 -0
  137. metadata +228 -0
@@ -0,0 +1,223 @@
1
+ # Decision: MCP Server (serving toolsets over Streamable HTTP)
2
+
3
+ > **Status: implemented in 0.0.4.**
4
+
5
+ **Date:** 2026-07-09
6
+ **Builds on:** [mcp-only-tooling.md](mcp-only-tooling.md) — toolsets as logical MCP
7
+ servers; this doc settles the open questions listed in its out-of-scope section.
8
+
9
+ ## Decision
10
+
11
+ `SolidLoop::Mcp.server(toolset, auth:)` builds a mountable Rack endpoint that
12
+ serves **one toolset** to external MCP clients over Streamable HTTP. Exposure
13
+ is the mount itself — no registry, no global config:
14
+
15
+ ```ruby
16
+ # config/routes.rb (host app)
17
+ mount SolidLoop::Mcp.server(RpcTools, auth: McpAuth.new) => "/rpc"
18
+ mount SolidLoop::Mcp.server(ReportTools, auth: ReportAuth.new) => "/tools"
19
+ # AdminTools is simply not mounted — it stays in-process only.
20
+ ```
21
+
22
+ Each endpoint is the mirror image of `Mcp::HttpTransport` over the same port:
23
+ the POST body is parsed and handed to `Toolset#deliver` — the exact method the
24
+ in-process agent path calls. There is no second dispatch implementation, so
25
+ local and remote behavior cannot drift. Define a toolset once; the durable
26
+ agent loop consumes it in-process, external agents (Claude Code, other apps)
27
+ consume the same tools remotely, with shared schemas, wire logs and admin.
28
+
29
+ ## Why mount-per-toolset (not one engine with a registry)
30
+
31
+ An earlier sketch had a single `ServerEngine` mount plus a
32
+ `config.mcp_server = { toolsets: [...] }` registry with path-per-`server_name`
33
+ routing. Rejected in favor of one mount per toolset:
34
+
35
+ - **Exposure is explicit and local.** What is served is exactly what is
36
+ mounted, readable in `routes.rb`; keeping a toolset internal means *not
37
+ writing a line*, instead of remembering to exclude it from a registry.
38
+ - **The host owns the paths.** `/rpc`, `/tools`, `/api/mcp/search` — any
39
+ path, independent of `server_name`; no coupling between a class-level name
40
+ and public URL layout.
41
+ - **Auth is per-server by construction.** Different toolsets can carry
42
+ different authorizers (or the same instance) without inventing per-entry
43
+ config syntax.
44
+ - **Less machinery.** No registry, no boot-time duplicate-`server_name`
45
+ validation, no `<mount>/<server_name>` routing layer — route uniqueness is
46
+ the router's job.
47
+
48
+ The endpoint is a plain Rack app (not a `Rails::Engine`): it serves a single
49
+ path with POST/GET/DELETE semantics and needs no routes, views or isolation.
50
+ It also keeps the auth posture structurally separate from the admin engine,
51
+ which is intentionally unauthenticated inside the gem (host-protected, like
52
+ Sidekiq Web) — the opposite of this endpoint (see Auth).
53
+
54
+ ## Auth: fail-closed, authorizer object, principal
55
+
56
+ - Transport: `Authorization: Bearer <token>` — symmetric with what
57
+ `HttpTransport` sends as a client.
58
+ - `auth:` is a **required argument** — `Mcp.server(toolset)` without it raises
59
+ at boot. Fail-closed is structural: an endpoint without an authorizer cannot
60
+ exist, in any environment. There is no anonymous mode and no development
61
+ bypass; serving without real auth must be written explicitly
62
+ (`auth: ->(_token, _request) { :anonymous }`).
63
+ - The authorizer is any object responding to
64
+ `call(token, request) -> principal | nil` — a lambda for trivial cases, a
65
+ host class when the logic deserves a home:
66
+
67
+ ```ruby
68
+ class McpAuth
69
+ def call(token, request)
70
+ client = ApiClient.active.find_by(token: token)
71
+ return nil unless client
72
+ return nil unless client.ip_allowed?(request.remote_ip)
73
+
74
+ client
75
+ end
76
+ end
77
+ ```
78
+
79
+ It receives the raw bearer token (`nil` if the header is absent or
80
+ malformed) and the `ActionDispatch::Request`. Returning `nil` → `401`,
81
+ empty body; the wire log records the rejection. Any other value becomes the
82
+ **principal** for the call.
83
+ - No token model / storage in the gem. Host apps already own API-token
84
+ persistence and rotation; an authorizer object subsumes bearer lists, DB
85
+ lookups and per-token IP allowlists without the gem designing any of them.
86
+
87
+ ## CallContext gains `principal`
88
+
89
+ ```ruby
90
+ CallContext = Struct.new(:agent, :loop, :principal, keyword_init: true)
91
+ ```
92
+
93
+ The principal is the answer to "*who is calling*". In-process, that answer is
94
+ already structural — the agent and its loop. An external caller has neither;
95
+ the principal (whatever the authorizer returned: an `ApiClient` record, a
96
+ user, a symbol) is the caller's identity for authorization decisions inside
97
+ handlers (tenant scoping, per-client capability checks), for the audit trail
98
+ (events/admin show who did what), and for the future HITL policy layer.
99
+
100
+ | Caller | `agent` / `loop` | `principal` |
101
+ |---|---|---|
102
+ | In-process agent (`toolset:` entry) | set | `Base#mcp_principal` (subject by default) |
103
+ | External client via `Mcp.server` | `nil` | authorizer's return value |
104
+
105
+ This is a **contract change for every toolset handler**: `ctx.agent` and
106
+ `ctx.loop` are no longer guaranteed. A toolset that reads loop state must
107
+ either guard (`ctx.loop or raise`) or simply never be mounted — serving is
108
+ opt-in per mount, so agent-only toolsets are unaffected by default. The
109
+ dummy-app toolsets are the canary; the guide documents the pattern.
110
+
111
+ `keyword_init` construction keeps every existing
112
+ `CallContext.new(agent:, loop:)` call site source-compatible.
113
+
114
+ ## Inbound sessions: dedicated model
115
+
116
+ A new table (new migration — the init migration is frozen since 0.0.3
117
+ published), not a reuse of `McpSession`:
118
+
119
+ ```ruby
120
+ create_table :solid_loop_mcp_inbound_sessions do |t|
121
+ t.string :server_name, null: false
122
+ t.string :session_id, null: false, index: { unique: true }
123
+ t.string :principal # human-readable label for admin (principal.to_s)
124
+ t.datetime :last_used_at
125
+ t.datetime :terminated_at
126
+ t.timestamps
127
+ end
128
+ ```
129
+
130
+ `McpSession` is shaped around the loop (`belongs_to :loop`, uniqueness scoped
131
+ to `loop_id`, `transport_kind`, tool sync) — none of which applies inbound.
132
+ Overloading it with a nullable loop and a `direction` flag would poison every
133
+ existing per-loop invariant for a model that shares only a name.
134
+
135
+ **Why persist inbound sessions at all** (toolsets are stateless by default,
136
+ and Streamable HTTP even allows running session-less):
137
+
138
+ 1. **Observability anchor.** `events.eventable` is `NOT NULL` polymorphic;
139
+ inbound wire logs need a record to hang off, and "this client's
140
+ connection" is the natural one — all calls of one client session group
141
+ under one row.
142
+ 2. **Admin visibility.** Who is connected to which server, as which
143
+ principal, since when, last activity — the shared-admin story is the
144
+ product's differentiator versus commodity MCP-server gems.
145
+ 3. **Session-id honoring.** Toolsets minting real ids via `on_initialize`
146
+ (same hook the in-process path honors) need the server side to recognize
147
+ them on subsequent requests and `404` unknown/expired ids
148
+ (spec-compliant re-initialize signal).
149
+
150
+ The session-less alternative (never emit `Mcp-Session-Id`, relax
151
+ `events.eventable`) was considered and rejected: it saves one small table but
152
+ loses all three points above.
153
+
154
+ Lifecycle:
155
+
156
+ - `initialize` → endpoint calls the toolset's `on_initialize`; `nil` → a UUID
157
+ is minted. Row created, id returned in the `Mcp-Session-Id` header.
158
+ - Subsequent requests carry `Mcp-Session-Id`; missing header → `400`,
159
+ unknown or terminated id → `404`; `last_used_at` touched on use.
160
+ - `DELETE` with a session header → session **terminated, row kept**
161
+ (`terminated_at`), `204`. Destroying the row would cascade into its events —
162
+ a client must not be able to erase its own audit trail with one request.
163
+ - No TTL/eviction in 0.0.4; rows are cheap and admin-visible. Retention can
164
+ ride the 0.0.5 storage work.
165
+
166
+ ## Observability
167
+
168
+ Every inbound request writes a `SolidLoop::Event`, same envelope shape as
169
+ outbound MCP events: `eventable` = the inbound session, `loop_id` = `nil`
170
+ (column is already nullable), `request_data`/`response_data` = the JSON-RPC
171
+ envelopes, plus a wire log. Admin: inbound sessions get index/show pages next
172
+ to outbound `McpSessions`; events appear in the existing Events UI.
173
+
174
+ ## Protocol scope (deliberately narrow)
175
+
176
+ | Feature | 0.0.4 answer |
177
+ |---|---|
178
+ | `POST` single JSON-RPC request | ✅ single `application/json` response |
179
+ | JSON-RPC notification (no `id`) | ✅ `202 Accepted`, empty body |
180
+ | `initialize` / `tools/list` / `tools/call` | ✅ via `Toolset#deliver` (unchanged) |
181
+ | `prompts/list` / `prompts/get` | ✅ new `prompt` DSL on `Toolset` (benefits the in-process path too); `prompts` capability advertised only when defined |
182
+ | `DELETE` (session termination) | ✅ `204` |
183
+ | **`GET` (SSE stream)** | ❌ **`405 Method Not Allowed`** — the spec's no-stream option; no half-working stream, revisit only if a real client cannot connect without it |
184
+ | JSON-RPC batch (array body) | ❌ `-32600` (removed from the MCP spec anyway) |
185
+ | `resources/*` (server side) | ❌ not in 0.0.4 (client-side *consumption* is a separate 0.0.4 workstream) |
186
+ | Server-initiated notifications / streamed `tools/call` | ❌ out of scope (unchanged from 0.0.3 decision) |
187
+ | Malformed JSON | `-32700` envelope |
188
+
189
+ Protocol errors ride HTTP 200 with a JSON-RPC `error` object (matching
190
+ `Toolset#deliver` today); HTTP status codes are reserved for transport-level
191
+ outcomes (401/404/405/202/204).
192
+
193
+ ## Positioning guardrail
194
+
195
+ `fast-mcp` and `action_mcp` already serve MCP from Rails — the endpoint is a
196
+ commodity. The differentiator is one toolset consumed by the durable agent
197
+ loop in-process *and* served externally, with shared schemas, wire logs, admin
198
+ and (future) HITL policy. Every feature request that exists to match dedicated
199
+ server gems is a scope trap; the table above is the tie-breaker.
200
+
201
+ ## Known trade-offs
202
+
203
+ - **`405` on GET locks out SSE-requiring clients.** Accepted: Streamable HTTP
204
+ explicitly permits servers that never open a stream, and the clients we
205
+ target (Claude Code, `McpClient` itself) operate request/response.
206
+ - **Object-based auth means no out-of-the-box token admin.** Accepted: the gem
207
+ avoids owning credential storage; hosts wire their existing API-auth into a
208
+ small authorizer class.
209
+ - **N toolsets → N mount lines.** Accepted: that verbosity *is* the exposure
210
+ audit trail.
211
+ - **Stateless bias.** Sessions minted by `on_initialize` are persisted, but
212
+ the endpoint offers no affinity or server-side state storage beyond the
213
+ row; a toolset holding real per-session state in memory will break under
214
+ multiple app processes — same restriction the in-process path already has,
215
+ now documented for the mount.
216
+
217
+ ## Out of scope (future work)
218
+
219
+ - Per-tool execution policy (`auto` / `requires_approval`) — the principal is
220
+ deliberately available to a future HITL layer, but 0.0.4 does not define it.
221
+ - Server-side `resources/*` DSL, SSE, rate limiting, inbound-session TTL.
222
+ - OAuth flows — bearer-token authorizer only; hosts needing OAuth terminate it
223
+ in front of the mount.
@@ -0,0 +1,51 @@
1
+ # Reasoning Persistence & Feedback Strategies
2
+
3
+ ## 1. The "Reasoning Amnesia" Problem
4
+
5
+ In multi-turn agentic workflows, Large Language Models (LLMs) generate "Chain of Thought" or "Reasoning" blocks. While these blocks are crucial for the model to arrive at a correct conclusion, most current APIs treat them as **output-only** fields.
6
+
7
+ **The Issue:**
8
+ If the reasoning block from Turn N is not sent back to the model in Turn N+1, the model loses the logical context of its own previous decisions. This often leads to:
9
+ * Repetitive errors.
10
+ * Logical inconsistencies.
11
+ * Loss of plan coherence (the model forgets decisions it just made).
12
+
13
+ ## 2. Multi-Strategy Approach (Array-based)
14
+
15
+ `SolidLoop` implements a flexible **Reasoning Protocol** to handle the inconsistent landscape of LLM providers. Instead of a single "hack," we use an **array of strategies** configured per agent/model. This allows combining multiple methods to ensure the model receives its thoughts regardless of how strict or fragmented the provider's API is.
16
+
17
+ ### Supported Strategies:
18
+
19
+ * **`:xml` (The DeepSeek/Reliability Standard):**
20
+ Wraps the reasoning content in `<think>...</think>` tags and prepends it to the main `content` string. This is the most robust method as it bypasses API field stripping.
21
+ *Note: In `SolidLoop`, this is a "Smart" strategy. It will automatically suppress itself if a native strategy (like `:gemini_signed`) is also present, avoiding redundant context.*
22
+ * **`:xml!` (Forced XML):**
23
+ Same as `:xml`, but **ignores** native strategy suppression. Use this if you want to ensure thoughts are visible in the main context even if the provider supports native fields.
24
+ * **`:gemini_signed` (The Google Standard):**
25
+ Passes both the `thought` text and the `thought_signature` (stored in JSONB metadata) back in specific fields required by Gemini 2.0+ for multi-turn reasoning.
26
+ * **`"field_name"` (Explicit Mapping):**
27
+ Allows copying the reasoning content into a specific JSON key (e.g., `"reasoning_content"`, `"thought"`, or `"internal_thinking"`) to satisfy various provider requirements (OpenRouter, vLLM, Groq).
28
+
29
+ ### Example Combinations:
30
+
31
+ * **`[:xml]`**: Maximum reliability for DeepSeek or OpenRouter (when field stripping is suspected).
32
+ * **`["reasoning", "reasoning_content"]`**: Populating multiple fields in parallel — useful when the provider's accepted field name is uncertain.
33
+ * **`[:gemini_signed, "thought", "reasoning_content"]`**: A comprehensive strategy for Gemini-compatible providers that might also look for OpenAI-style fields.
34
+ * **`[]` (Empty Array)**: Standard for OpenAI o1/o3 (reasoning is stripped to avoid 400 errors).
35
+
36
+ ## 3. The Reasoning Verifier *(not implemented)*
37
+
38
+ To ensure an agent is actually "hearing its own thoughts," a **Reasoning Verification Test** can be written:
39
+
40
+ 1. **Inject a Secret:** Create a mock Turn 1 where the `reasoning` block contains a specific "Thought Secret" (e.g., *"The hidden password is 'SOLID-TANK'"*), while the `content` is generic.
41
+ 2. **Ask for Recall:** In Turn 2, ask the model: *"What was the hidden password you were thinking about in the previous step?"*.
42
+ 3. **Validate Strategy:**
43
+ * If the model answers correctly, the current `reasoning_strategies` are working.
44
+ * If it fails, the provider is stripping the thoughts, and a more aggressive strategy (like `:xml`) is required.
45
+
46
+ Worth doing — it would give a cheap, repeatable way to verify that `ReasoningPacker` is actually working end-to-end with a given provider/strategy combination, rather than assuming it does.
47
+
48
+ ## 4. Data Storage
49
+
50
+ * **Reasoning Text:** Stored in the `reasoning_content` column of `solid_loop_messages`.
51
+ * **Signatures & Metadata:** Stored in the `metadata` JSONB column. This ensures we can support future tokens like `thought_signature` without schema migrations.
@@ -0,0 +1,35 @@
1
+ # Architectural Decision Record: Why we rejected ruby_llm as an Adapter
2
+
3
+ **Date:** 2026-03-18
4
+ **Status:** Rejected
5
+ **Context:** Investigated integrating `ruby_llm` (v1.14) as a high-level adapter for the `SolidLoop` gem.
6
+
7
+ ## The Problem
8
+ `SolidLoop` is designed around three constraints that every agent step must satisfy:
9
+ 1. **Persistent:** Stored in the database (`ActiveRecord`).
10
+ 2. **Asynchronous:** Executed via background workers (`ActiveJob`).
11
+ 3. **Observable:** Raw wire-level logs available for debugging.
12
+
13
+ ## Why `ruby_llm` Failed to Align
14
+
15
+ ### 1. The "In-Memory Loop" vs. "Database Orchestration"
16
+ `ruby_llm` is optimized for interactive, in-process workflows where development speed is the priority — a design that serves its goals well. Its `Chat` object manages its own **in-memory execution loop**:
17
+ * **Automatic Execution:** When a model returns a `tool_call`, `ruby_llm` **immediately executes the tool's code** and triggers the next LLM completion in the same process.
18
+ * **The conflict:** `SolidLoop` requires that the process **must STOP** after an LLM response. We must persist the `tool_call` to the database and delegate execution to a separate, asynchronous `ActiveJob`.
19
+ * **Risk:** Using an in-memory loop makes the agent fragile—if the process crashes during a tool call, the entire state is lost. `SolidLoop` solves this by making the DB the "source of truth" for every single turn.
20
+
21
+ ### 2. Loss of Observability (Wire Logs)
22
+ `SolidLoop` exposes raw Faraday-based HTTP logs via `env.log_buffer`, which is important for production debugging.
23
+ * **The conflict:** `ruby_llm` encapsulates Faraday inside a private `Connection` class. It does not expose an easy way to inject custom Faraday middleware and uses its own logging format.
24
+
25
+ ### 3. Tight Coupling & High Abstraction
26
+ `ruby_llm` expects tools to be registered as its own `Tool` objects. `SolidLoop` uses raw OpenAI-compatible JSON schemas (often coming from MCP servers).
27
+ * **The conflict:** Converting between schemas and mocking internal tool objects (`params_schema` errors) added unnecessary complexity and maintenance burden.
28
+
29
+ ## Final Decision
30
+ We decided to double down on our **Native Adapter** approach.
31
+
32
+ ### Benefits of the Native Path:
33
+ * Direct use of Faraday gives full control over headers, timeouts, and logs.
34
+ * Straightforward integration with the `ActiveRecord` + `ActiveJob` cycle.
35
+ * Adding provider-specific payload mapping (e.g. Anthropic, Gemini) is simpler than adapting to a high-level framework's abstractions.
@@ -0,0 +1,55 @@
1
+ # LLM Dialects in SolidLoop
2
+
3
+ ## 1. Concept
4
+
5
+ While the `Adapter` handles the **Network Transport** (Faraday, Logging, SSE), the `Dialect` handles the **Language of the Model**.
6
+
7
+ Each LLM provider (OpenAI, Gemini, Anthropic) has its own JSON structure for requests and responses. Instead of embedding `if/else` blocks in the adapter, `SolidLoop` uses Dialects to map the internal standard to each provider's specific format.
8
+
9
+ ## 2. Default Dialects
10
+
11
+ ### `SolidLoop::Dialects::OpenAi` (Default)
12
+ The standard engine for OpenAI, Ollama, vLLM, and most OpenAI-compatible APIs.
13
+ * **URL:** `/v1/chat/completions`
14
+ * **Payload:** Standard `messages` and `tools` arrays.
15
+ * **Parsing:** Looks for data in `choices[0].message`.
16
+
17
+ ### `SolidLoop::Dialects::Gemini`
18
+ A native dialect for Google Gemini (AI Studio / Vertex AI).
19
+ * **URL:** `/v1beta/models/...`
20
+ * **Payload:** Translates OpenAI-style messages to Gemini's `contents/parts` format.
21
+ * **Roles:** Automatically maps `assistant` role to `model`.
22
+ * **Parsing:** Extracts data from Gemini's specific candidate structure and handles `thought_signature`.
23
+
24
+ ## 3. Configuration in Agents
25
+
26
+ An agent defines its preferred adapter and dialect through methods. This allows the same adapter (e.g., `Native`) to speak different languages.
27
+
28
+ ```ruby
29
+ class MyCustomAgent < SolidLoop::Base
30
+ # Which network engine to use
31
+ def llm_adapter_name
32
+ :native
33
+ end
34
+
35
+ # Which JSON dialect to speak
36
+ def llm_dialect_name
37
+ :gemini
38
+ end
39
+
40
+ # How to return thoughts to the model
41
+ def reasoning_strategies
42
+ [:gemini_signed, "reasoning_content"]
43
+ end
44
+ end
45
+ ```
46
+
47
+ ## 4. Dialect Interface
48
+
49
+ A Dialect object must implement the following methods to be compatible with the `Native` adapter:
50
+
51
+ 1. **`completion_url(base_url)`**: Returns the specific API endpoint.
52
+ 2. **`render_payload(universal_payload)`**: (Optional) Transforms the payload before sending.
53
+ 3. **`apply_reasoning_strategies!(messages, strategies)`**: Formats message history according to the [Reasoning Protocol](./reasoning_persistence.md).
54
+ 4. **`extract_message_data(raw_json)`**: Extracts content and tools from a streaming chunk or partial response.
55
+ 5. **`normalize_response(raw_json)`**: Converts a full API response into the SolidLoop universal format (including token usage and metadata).
@@ -0,0 +1,294 @@
1
+ # LLM Middlewares Architecture in SolidLoop
2
+
3
+ ## 1. Middleware Principles
4
+
5
+ In `SolidLoop`, the generation of LLM responses (the `LlmCompletionJob` class) is built upon the classic **Chain of Responsibility (Middleware)** pattern, widely used in Rack (Ruby) or Express.js (Node).
6
+
7
+ **Why is this needed?**
8
+ Instead of having one massive, monolithic `perform` method, the logic is broken down into isolated layers (Middlewares). Each layer is responsible for exactly one specific task (e.g., error handling, payload building, network requests).
9
+
10
+ ### The Middleware Stack (Layered View)
11
+
12
+ `SolidLoop` processes LLM interactions through a robust, layered stack:
13
+
14
+ ```text
15
+ ┌──────────────────────────────────────────────────────────┐
16
+ │ 1. ErrorHandling (Guard) │ ← Catch-all for stability
17
+ ├──────────────────────────────────────────────────────────┤
18
+ │ 2. AgentInitialization (Context) │ ← Agent logic & MCP setup
19
+ ├──────────────────────────────────────────────────────────┤
20
+ │ 3. MessageBuilding (Assembler) │ ← DB to OpenAI-style payload
21
+ ├──────────────────────────────────────────────────────────┤
22
+ │ 4. EventLogging (Auditor) │ ← Full persistence of logs
23
+ ├──────────────────────────────────────────────────────────┤
24
+ │ 5. NetworkCalling (Orchestrator) │ ← Orchestrates LLM Adapters
25
+ │ │ │
26
+ │ │ ┌─────────────────────────────────────────────┐ │
27
+ │ └───┤ Adapter: Native (The Default Engine) ├───┘
28
+ │ │ - Clean Faraday Transport │
29
+ │ │ - Raw Wire Logging │
30
+ │ │ - Shopify EventStreamParser (SSE) │
31
+ │ └─────────────────────────────────────────────┘
32
+ ├──────────────────────────────────────────────────────────┤
33
+ │ 6. ResponseParsing (Finalizer) │ ← Final DB commit & metrics
34
+ └──────────────────────────────────────────────────────────┘
35
+ ```
36
+
37
+ **How it works:**
38
+ - Middlewares are arranged in a strict **chain (Pipeline)**.
39
+ - They pass a shared state object to each other — the **`Context`** (`SolidLoop::Pipeline::Context`).
40
+ - There are two phases of data flow:
41
+ - **"Downstream" (Request phase):** Execution flows top-to-bottom. A layer prepares data (e.g., formats messages) and passes control to the next layer (`@app.call(env)`).
42
+ - **"Upstream" (Response phase):** Execution returns bottom-to-top. A layer receives the response from nested layers (e.g., the HTTP network response) and can modify it or save results to the database.
43
+ - Any layer can halt the chain simply by not calling `@app.call(env)` (for example, if a rate limit is hit or the loop was cancelled by the user).
44
+
45
+ **Extensibility (Custom Middlewares):**
46
+ Host applications can inject their own custom layers into this chain without modifying the core gem's source code.
47
+
48
+ There are two levels of customization:
49
+ - **Global:** configure `config.llm_middlewares` in the SolidLoop initializer using the `Pipeline::Builder` DSL (`insert_before`, `insert_after`, `use`, `replace`, `delete`).
50
+ - **Per-agent:** implement `configure_llm_middlewares(builder)` on your agent instance. This runs for each `LlmCompletionJob`, after the default stack is copied into a fresh builder and before the pipeline executes.
51
+
52
+ For example, suppose you want to inject a custom `SummarizationMiddleware` that compresses older messages to save tokens before sending the payload to the LLM:
53
+
54
+ ```ruby
55
+ # config/initializers/solid_loop.rb
56
+ SolidLoop.configure do |config|
57
+ config.llm_middlewares.insert_after(SolidLoop::Middlewares::AgentInitialization, SummarizationMiddleware)
58
+ end
59
+
60
+ # app/services/summarization_middleware.rb
61
+ class SummarizationMiddleware
62
+ def initialize(app)
63
+ @app = app
64
+ end
65
+
66
+ def call(env)
67
+ # REQUEST PHASE: We can intercept and modify the state BEFORE the payload is built.
68
+ # For example, let's keep only the last 10 messages if there are too many.
69
+ if env.loop.messages.count > 10
70
+ # Abstract summarization logic:
71
+ # env.loop.messages = CustomSummarizer.compress(env.loop.messages)
72
+ Rails.logger.info "SummarizationMiddleware: Compressed context for loop #{env.loop.id}"
73
+ end
74
+
75
+ # Pass control down the chain (to MessageBuilding, NetworkCalling, etc.)
76
+ @app.call(env)
77
+
78
+ # RESPONSE PHASE: Execution returns here after the LLM responds.
79
+ # (Optional: do something with env.response if needed)
80
+ end
81
+ end
82
+ ```
83
+
84
+ Another example is **Domain Object Rewriting**. Some legacy or local models return tool calls as raw `<tool_call>` XML tags in the text `content` rather than a standard JSON array. `SolidLoop` ships with a built-in (but disabled by default) `ToolCallXmlParser` middleware.
85
+
86
+ It should be inserted **before** `ResponseParsing` in the builder. That way it wraps `ResponseParsing`, lets the core parser create the assistant message first, and then runs on the response path to inspect `env.assistant_message`, extract XML tool calls, and create the `tool_calls` records in the database.
87
+
88
+ If a specific agent always needs this legacy behavior, prefer the per-agent hook:
89
+
90
+ ```ruby
91
+ # app/agents/my_agent.rb
92
+ class MyAgent < SolidLoop::Base
93
+ def configure_llm_middlewares(builder)
94
+ builder.insert_before(
95
+ SolidLoop::Middlewares::ResponseParsing,
96
+ SolidLoop::Middlewares::ToolCallXmlParser
97
+ )
98
+ end
99
+ end
100
+ ```
101
+
102
+ If every loop for an application needs the same behavior, configure it globally instead:
103
+
104
+ ```ruby
105
+ # config/initializers/solid_loop.rb
106
+ SolidLoop.configure do |config|
107
+ config.llm_middlewares.insert_before(
108
+ SolidLoop::Middlewares::ResponseParsing,
109
+ SolidLoop::Middlewares::ToolCallXmlParser
110
+ )
111
+ end
112
+ ```
113
+
114
+ The core `ResponseParsing` layer never needs to know XML was involved, and the legacy compatibility stays isolated to a dedicated middleware.
115
+
116
+ ---
117
+
118
+ ## 2. Default LLM Middlewares Stack
119
+
120
+ When `LlmCompletionJob` is executed, the following layers run in strict order by default:
121
+
122
+ ### 1. `ErrorHandling`
123
+ * **What it does:** Wraps the entire context-gathering and networking process in a `begin/rescue` block.
124
+ * **Request Phase:** Simply passes control down the chain.
125
+ * **Response Phase:** Catches any exceptions (`StandardError`) thrown by deeper layers. It logs the error with a backtrace and correctly transitions the `SolidLoop::Loop` and its `SolidLoop::Message` to a `failed` status. An exception is `CancellationError`, which quietly stops the thread and marks the status as `success`.
126
+
127
+ ### 2. `AgentInitialization`
128
+ * **What it does:** Prepares the AI Agent instance and initializes connections with the MCP servers assigned to the current loop.
129
+ * **Request Phase:**
130
+ 1. Reads the agent class name from the DB and instantiates it.
131
+ 2. Runs `McpSessionInitializer` to establish connections (TCP/IP or stdio) with all tools before building the formatted text block or JSON structure of the prompts.
132
+
133
+ ### 3. `MessageBuilding`
134
+ * **What it does:** Prepares the data payload for the LLM provider.
135
+ * **Request Phase:**
136
+ 1. Retrieves all previous `Loop.messages` from the DB.
137
+ 2. Formats them into an array of JSON hashes (`[{role: "user", content: "..."}]`).
138
+ 3. Attaches reasoning data from previous turns as internal keys (`_sl_reasoning`, `_sl_signature`) — the adapter will apply the configured `reasoning_strategies` and remove these keys before sending.
139
+ 4. Forms the final `payload` hash that will be sent via HTTP.
140
+
141
+ ### 4. `EventLogging`
142
+ * **What it does:** Responsible for logging requests and responses for debugging purposes (saves records to the `SolidLoop::Event` table).
143
+ * **Request Phase:** Creates an `Event` record in the database with a draft of the request (URL, Headers, assembled Payload). Records the start time.
144
+ * **Response Phase:** Updates the created `Event`, writing the received JSON network response, raw stream chunks (if applicable), and total execution duration.
145
+
146
+ ### 5. `NetworkCalling` & The Adapter Layer
147
+ * **What it does:** The orchestrator of network communication. It doesn't perform HTTP calls directly but delegates them to an **Adapter** (e.g., `SolidLoop::Adapters::Native`).
148
+ * **Request Phase:**
149
+ 1. Instantiates `SolidLoop::Adapters::Native` (the only built-in adapter).
150
+ 2. If **Streaming** is enabled, it creates a `SolidLoop::Message` in the DB and subscribes to the adapter's chunk callbacks to deliver incremental content in real-time.
151
+ 3. Executes the adapter's `call` method.
152
+ * **Adapter (Native):** The built-in engine that uses **Faraday** for transport and provides raw wire logs. It integrates **Shopify's `event_stream_parser`** for SSE streaming.
153
+ * **Response Phase:** Captures the response, duration, and logs (merged back into `env.log_buffer`) from the adapter and saves them into the `Context`.
154
+
155
+ ### 6. `ResponseParsing`
156
+ * **What it does:** Parses the network result and saves the assistant's message to the database. **Operates exclusively on the return path (Response Phase).**
157
+ * **Response Phase:**
158
+ 1. Reads `Context.response` or the aggregator's result.
159
+ 2. Extracts the response text (`content`) and separates reasoning blocks (`reasoning_content`, `thought`).
160
+ 3. Parses tool calls (`tool_calls`) from the normalized response data.
161
+ 4. Extracts meta-token statistics (`usage`) using **`SolidLoop::LlmMetrics`** (calculating TTFT, TPS, and duration).
162
+ 5. Saves (or updates) the final `SolidLoop::Message` record with the `assistant` role in the database.
163
+ 6. If the LLM invoked tools, this layer enqueues `SolidLoop::ToolExecutionJob` jobs. If no tools were called, it marks the `Loop` as `completed`.
164
+
165
+ ---
166
+
167
+ ## 3. Real-World Recipes
168
+
169
+ ### Recipe: Provider Payload Transformation (MCP tool names with `/`)
170
+
171
+ **The problem.** MCP tools are named with `/` by protocol convention: `files/write_file`, `search/get_results`. OpenAI rejects these with a 400 error — it validates function names against `^[a-zA-Z0-9_-]+$`. The MCP server can't be changed, and forking the gem to patch it is not an option.
172
+
173
+ **The solution.** Two middlewares in a symmetric pair: one encodes before the request, one decodes after.
174
+
175
+ ```
176
+ [payload: files/write_file]
177
+
178
+ ToolNameEncoder → files__write_file (insert_before NetworkCalling)
179
+
180
+ NetworkCalling → OpenAI sees a valid name
181
+
182
+ ToolNameDecoder → files/write_file (insert_after NetworkCalling)
183
+
184
+ ResponseParsing → original name saved to DB, MCP routing works correctly
185
+ ```
186
+
187
+ **Key detail:** the encoder must patch two places in the payload, not just the tool list:
188
+ 1. `tools[].function.name` — the tool definitions sent to the LLM
189
+ 2. `messages[].tool_calls[].function.name` — the conversation history, because OpenAI validates past tool_call names in the context window too
190
+
191
+ The decoder only touches `env.normalized_data` (the LLM response) — exactly what needs to be restored before `ResponseParsing` writes to the database.
192
+
193
+ ```ruby
194
+ # app/services/solid_loop/middlewares/tool_name_encoder.rb
195
+ module SolidLoop
196
+ module Middlewares
197
+ class ToolNameEncoder
198
+ def initialize(app) = @app = app
199
+
200
+ def call(env)
201
+ if env.payload
202
+ encode_tools!(env.payload)
203
+ encode_messages!(env.payload)
204
+ end
205
+ @app.call(env)
206
+ end
207
+
208
+ private
209
+
210
+ def encode(name) = name.to_s.gsub("/", "__")
211
+
212
+ def encode_tools!(payload)
213
+ return unless payload[:tools]
214
+ payload[:tools] = payload[:tools].map do |tool|
215
+ next tool unless tool.dig(:function, :name)
216
+ tool.merge(function: tool[:function].merge(name: encode(tool[:function][:name])))
217
+ end
218
+ end
219
+
220
+ def encode_messages!(payload)
221
+ return unless payload[:messages]
222
+ payload[:messages] = payload[:messages].map do |msg|
223
+ msg = msg.dup
224
+ if msg[:tool_calls]
225
+ msg[:tool_calls] = msg[:tool_calls].map do |tc|
226
+ next tc unless tc.dig(:function, :name)
227
+ tc.merge(function: tc[:function].merge(name: encode(tc.dig(:function, :name))))
228
+ end
229
+ end
230
+ msg[:name] = encode(msg[:name]) if msg[:name]
231
+ msg
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+ ```
238
+
239
+ ```ruby
240
+ # app/services/solid_loop/middlewares/tool_name_decoder.rb
241
+ module SolidLoop
242
+ module Middlewares
243
+ class ToolNameDecoder
244
+ def initialize(app) = @app = app
245
+
246
+ def call(env)
247
+ decode_normalized_data!(env)
248
+ @app.call(env)
249
+ end
250
+
251
+ private
252
+
253
+ def decode(name) = name.to_s.gsub("__", "/")
254
+
255
+ def decode_normalized_data!(env)
256
+ return unless env.normalized_data&.dig(:tool_calls)
257
+ env.normalized_data[:tool_calls].each do |tc|
258
+ next unless tc.dig("function", "name")
259
+ tc["function"]["name"] = decode(tc["function"]["name"])
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
265
+ ```
266
+
267
+ ```ruby
268
+ # config/initializers/solid_loop.rb
269
+ SolidLoop.configure do |config|
270
+ config.llm_middlewares.insert_before(SolidLoop::Middlewares::NetworkCalling,
271
+ SolidLoop::Middlewares::ToolNameEncoder)
272
+ config.llm_middlewares.insert_after(SolidLoop::Middlewares::NetworkCalling,
273
+ SolidLoop::Middlewares::ToolNameDecoder)
274
+ end
275
+ ```
276
+
277
+ **The takeaway.** Provider-specific constraints are a moving target. The middleware pipeline lets you adapt to them in your application code, without forking the gem, without modifying the MCP server, and without leaking provider quirks into your agent logic. The fix is a small, self-contained middleware pair.
278
+
279
+ ---
280
+
281
+ ### General pattern: symmetric encoder/decoder around `NetworkCalling`
282
+
283
+ Any transformation that must be invisible to the LLM provider but transparent to the rest of the system follows this shape:
284
+
285
+ ```
286
+ insert_before(NetworkCalling) → encode / normalize / adapt
287
+ insert_after(NetworkCalling) → decode / restore / revert
288
+ ```
289
+
290
+ Examples where this applies:
291
+ - Tool name character restrictions (shown above)
292
+ - Payload size limits: compress large tool results before sending, decompress in response phase
293
+ - Provider-specific authentication headers injected per-request
294
+ - Request signing for self-hosted models behind an API gateway