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
|
@@ -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
|