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,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ module Middlewares
5
+ class ResponseParsing
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ # We always call the inner app first (network). ResponseParsing acts on the way back (Upstream).
12
+ @app.call(env)
13
+
14
+ if env.response&.success?
15
+ process_result(env)
16
+ elsif env.response
17
+ handle_error(env)
18
+ end
19
+ # Exceptions are left to ErrorHandling middleware
20
+ end
21
+
22
+ private
23
+
24
+ def process_result(env)
25
+ # Use normalized data from the adapter/dialect if available
26
+ data = if env.normalized_data.present?
27
+ env.normalized_data
28
+ elsif env.agent.streaming?
29
+ # Fallback for old aggregator usage (should not happen with new Native adapter)
30
+ SolidLoop::Dialects::OpenAi.new.normalize_response(env.aggregator.result)
31
+ else
32
+ # Fallback for non-streaming old-style Faraday responses
33
+ raw_json = JSON.parse(env.response.body) rescue {}
34
+ SolidLoop::Dialects::OpenAi.new.normalize_response(raw_json)
35
+ end
36
+
37
+ content = data[:content]
38
+ reasoning_content = data[:reasoning]
39
+ tool_calls = (data[:tool_calls] || []).dup
40
+ usage = data[:usage] || {}
41
+ metadata = (data[:metadata] || {}).dup
42
+
43
+ # Stamp the model that served this turn onto the message metadata. Prefer
44
+ # the model the provider echoed back in its response (surfaced by the
45
+ # dialect's normalize_response as metadata["model"]) and fall back to the
46
+ # model we requested for this turn (always present via model_config). This
47
+ # is a per-message, immutable record: the same loop may have turns served
48
+ # by different models over its lifetime.
49
+ served_model = metadata["model"].presence || env.model_config&.dig(:model)
50
+ metadata["model"] = served_model if served_model.present?
51
+
52
+ ttft = env.first_token_at ? (env.first_token_at - env.request_started_at).to_f : 0.0
53
+ duration_thinking = if env.content_started_at && env.first_token_at
54
+ (env.content_started_at - env.first_token_at).to_f
55
+ else
56
+ 0.0
57
+ end
58
+
59
+ # Calculate duration based on request start
60
+ full_duration = env.duration || (env.request_started_at ? (Time.current - env.request_started_at).to_f : 0.0)
61
+
62
+ # usage is already a normalized hash from the dialect
63
+ usage_data = usage
64
+
65
+ msg_params = {
66
+ content: content,
67
+ reasoning_content: reasoning_content,
68
+ status: "success",
69
+ # The canonical commit is the ONLY place a born-hidden streaming shell
70
+ # becomes visible history. Also correct for the
71
+ # non-streaming path, where we create the assistant row fresh here.
72
+ is_hidden: false,
73
+ tokens_prompt: usage_data[:tokens_prompt],
74
+ tokens_completion: usage_data[:tokens_completion],
75
+ tokens_total: usage_data[:tokens_total],
76
+ tokens_prompt_cached: usage_data[:tokens_prompt_cached],
77
+ tps: usage_data[:tps],
78
+ ttft: ttft,
79
+ duration_thinking: duration_thinking,
80
+ duration_generation: full_duration,
81
+ tool_calls_raw: tool_calls,
82
+ metadata: metadata,
83
+ cost: usage_data[:cost] || 0.0
84
+ }
85
+
86
+ # If the background heartbeat has LOST the lease (a renew
87
+ # touched 0 rows because the generation rotated away or the reaper
88
+ # reclaimed us), raise BEFORE any canonical write. The with_generation
89
+ # token fence below would also reject a stale commit, but raising here
90
+ # keeps the "no canonical write after a lost lease" contract explicit and
91
+ # surfaces the lost turn to the job's LostLease rescue.
92
+ env.heartbeat&.check!
93
+
94
+ # The heartbeat's job ENDS the instant we decide to commit — stop it
95
+ # BEFORE the canonical commit so no renew can race the lease-clear. On the
96
+ # tool-phase success path the commit leaves the loop `running` + same
97
+ # token + lease NULL ("tool phase, not an LLM turn"); a renew firing after
98
+ # that commit but before the outer `ensure` stop would STILL match
99
+ # (running + our token) and re-populate the lease, falsely reclaiming a
100
+ # healthy tool phase. Stopping here removes that window entirely; the outer
101
+ # `ensure` stop remains (idempotent) for every non-commit exit path.
102
+ env.heartbeat&.stop
103
+
104
+ # Every generation-owned write (turn persistence, message states,
105
+ # counters, tool-call rows and their successor-job enqueues) happens
106
+ # under the loop row lock AFTER re-validating that this generation
107
+ # still owns the loop. A stale worker performs zero mutations.
108
+ msg = nil
109
+ owned = env.loop.with_generation(env.execution_token) do
110
+ # The canonical commit ends the in-flight LLM turn: clear the lease so
111
+ # the loop leaves the reclaimable LLM-turn state. In the tool-phase
112
+ # branch below the loop stays `running` but with lease NULL (= "tool
113
+ # phase, not an LLM turn"); terminal/queued branches re-clear it too.
114
+ env.loop.update!(lease_expires_at: nil) if env.loop.lease_expires_at.present?
115
+
116
+ env.triggering_message&.update!(status: "success")
117
+
118
+ if env.assistant_message
119
+ msg_params[:metadata] = (env.assistant_message.metadata || {}).merge(msg_params[:metadata] || {})
120
+ env.assistant_message.update!(msg_params)
121
+ msg = env.assistant_message
122
+ else
123
+ begin
124
+ msg = env.loop.messages.create!(msg_params.merge(role: "assistant"))
125
+ rescue ActiveRecord::RecordInvalid => e
126
+ Rails.logger.error "Failed to create message: #{e.record.errors.full_messages.join(', ')} Params: #{msg_params}"
127
+ raise e
128
+ end
129
+ end
130
+
131
+ counters_to_add = {
132
+ step_count: 1,
133
+ tokens_prompt: msg.tokens_prompt || 0,
134
+ tokens_completion: msg.tokens_completion || 0,
135
+ tokens_total: msg.tokens_total || 0,
136
+ tokens_prompt_cached: msg.tokens_prompt_cached || 0,
137
+ duration_generation: full_duration,
138
+ cost: msg.cost || 0.0
139
+ }.delete_if { |_, v| v == 0 }
140
+
141
+ env.loop.class.update_counters(env.loop.id, counters_to_add) if counters_to_add.any?
142
+
143
+ if tool_calls&.any?
144
+ process_tool_calls(env, msg, tool_calls)
145
+ elsif env.loop.pending_user_messages.exists?
146
+ # Steering that arrived while this final (no-tool-call) request was in
147
+ # flight is a hidden, still-pending user message. Completing here would
148
+ # strand it forever. Instead requeue for another turn — the next
149
+ # MessageBuilding#build_messages drains and delivers it. Ownership is
150
+ # already validated under the held lock, and the enqueue commits inside
151
+ # this same transaction (a failed enqueue rolls the requeue back).
152
+ env.loop.update!(status: :queued, execution_token: nil, lease_expires_at: nil)
153
+ SolidLoop.enqueue!(SolidLoop::LlmCompletionJob, env.loop.id)
154
+ else
155
+ # Ownership is already validated under the held lock.
156
+ env.loop.update!(status: :completed, execution_token: nil, lease_expires_at: nil)
157
+ end
158
+ end
159
+ return unless owned
160
+
161
+ env.assistant_message&.broadcast_update
162
+
163
+ env.agent.on_message_created(msg) if env.agent.respond_to?(:on_message_created)
164
+
165
+ if env.loop.observe_enabled?
166
+ if defined?(Turbo::StreamsChannel)
167
+ SolidLoop::ObserveBroadcastJob.perform_later(msg.id)
168
+ else
169
+ Rails.logger.warn(
170
+ "[SolidLoop] observe_enabled is on for loop #{env.loop.id} but turbo-rails is not " \
171
+ "available; skipping live broadcast. Add `turbo-rails` to your host application to " \
172
+ "enable live observation of agent loops."
173
+ )
174
+ end
175
+ end
176
+ end
177
+
178
+ def process_tool_calls(env, msg, tool_calls)
179
+ invalid_tool_calls = []
180
+
181
+ # Persistence and successor-job dispatch commit atomically: the
182
+ # enqueue happens INSIDE the same transaction as the tool-call rows
183
+ # (SolidLoop requires a same-database transactional job backend), so a
184
+ # crash or rollback leaves neither the state change nor a dangling job.
185
+ msg.transaction do
186
+ tool_calls.each do |tc|
187
+ begin
188
+ args = JSON.parse(tc.dig("function", "arguments") || "{}")
189
+ msg.tool_calls.create!(
190
+ tool_call_id: tc["id"],
191
+ function_name: tc.dig("function", "name"),
192
+ arguments: args
193
+ )
194
+ rescue JSON::ParserError => e
195
+ error_msg = build_invalid_tool_call_message(tc, e)
196
+ Rails.logger.error error_msg
197
+ env.loop.messages.create!(
198
+ role: "tool",
199
+ tool_call_id: tc["id"],
200
+ content: error_msg,
201
+ status: "success"
202
+ )
203
+ invalid_tool_calls << tc
204
+ end
205
+ end
206
+
207
+ persisted_tool_calls = msg.tool_calls.ordered.to_a
208
+
209
+ # Parallel (default): enqueue every call in the turn so workers run
210
+ # them concurrently. Sequential: enqueue only the first;
211
+ # ResponseCreation chains the next one after each result lands.
212
+ calls_to_dispatch =
213
+ if env.agent.sequential_tool_calls?
214
+ persisted_tool_calls.first(1)
215
+ else
216
+ persisted_tool_calls
217
+ end
218
+
219
+ calls_to_dispatch.each do |tool_call|
220
+ SolidLoop.enqueue!(SolidLoop::ToolExecutionJob, tool_call.id, env.execution_token)
221
+ end
222
+
223
+ if persisted_tool_calls.empty? && invalid_tool_calls.any?
224
+ transitioned = env.loop.transition_status(
225
+ from: :running,
226
+ to: :queued,
227
+ expected_execution_token: env.execution_token,
228
+ execution_token: nil,
229
+ lease_expires_at: nil
230
+ )
231
+ SolidLoop.enqueue!(SolidLoop::LlmCompletionJob, env.loop.id) if transitioned
232
+ end
233
+ end
234
+ end
235
+
236
+ def build_invalid_tool_call_message(tc, error)
237
+ function_name = tc.dig("function", "name") || "unknown_tool"
238
+ raw_arguments = tc.dig("function", "arguments").to_s
239
+ pointer = json_error_pointer(raw_arguments, error)
240
+
241
+ <<~MSG.strip
242
+ Tool call rejected: `#{function_name}`
243
+
244
+ I could not use this tool because its arguments were not valid JSON.
245
+ Please send the same tool call again with valid JSON only.
246
+
247
+ What went wrong: #{error.message}
248
+ Raw arguments:
249
+ #{raw_arguments.presence || "(empty)"}
250
+ #{pointer}
251
+ MSG
252
+ end
253
+
254
+ def json_error_pointer(raw_arguments, error)
255
+ return nil if raw_arguments.blank?
256
+
257
+ offset = error.message[/column (\d+)/, 1]&.to_i
258
+ return nil unless offset && offset > 0
259
+
260
+ "#{' ' * (offset - 1)}^"
261
+ end
262
+
263
+ # Cap for the failing-response body slice that lands in error_message and
264
+ # the persisted Event, so a runaway provider error page can't bloat rows.
265
+ MAX_ERROR_BODY = 8_192
266
+
267
+ def handle_error(env)
268
+ response = env.response
269
+ status = response.status
270
+ body = error_response_body(env, response)
271
+
272
+ # Expose the failing response envelope so EventLogging can persist the
273
+ # provider's status/headers/body in the wire-log Event (undebuggable
274
+ # otherwise — especially streaming, where response.body is drained).
275
+ env.error_status = status
276
+ env.error_headers = safe_response_headers(response)
277
+ env.error_body = body
278
+
279
+ error_msg = "HTTP #{status}: #{body}"
280
+
281
+ is_context_limit = (status == 400) && (
282
+ body.include?("maximum context length") ||
283
+ body.include?("reduce the length of the input messages") ||
284
+ body.include?("context_length_exceeded")
285
+ )
286
+
287
+ # Duration fallback
288
+ full_duration = env.duration || (env.request_started_at ? (Time.current - env.request_started_at).to_f : 0.0)
289
+
290
+ # Message state and counters are generation-owned: they run inside the
291
+ # status CAS (under the loop lock), so a stale worker whose token lost
292
+ # performs zero mutations.
293
+ transitioned =
294
+ if is_context_limit
295
+ env.loop.transition_status(
296
+ from: :running,
297
+ to: :completed,
298
+ expected_execution_token: env.execution_token,
299
+ error_message: "Context limit reached: #{error_msg}",
300
+ execution_token: nil,
301
+ lease_expires_at: nil
302
+ ) do
303
+ env.triggering_message&.update!(status: "success")
304
+ env.loop.class.update_counters(env.loop.id, step_count: 1, duration_generation: full_duration)
305
+ end
306
+ else
307
+ env.loop.transition_status(
308
+ from: :running,
309
+ to: :failed,
310
+ expected_execution_token: env.execution_token,
311
+ error_message: error_msg,
312
+ execution_token: nil,
313
+ lease_expires_at: nil
314
+ ) do
315
+ env.triggering_message&.update!(status: "failed", error_message: error_msg)
316
+ env.loop.class.update_counters(env.loop.id, step_count: 1, duration_generation: full_duration)
317
+ end
318
+ end
319
+
320
+ env.triggering_message&.broadcast_update if transitioned
321
+ end
322
+
323
+ # Faraday's non-2xx response body is usually intact for sync calls, but a
324
+ # streaming request drains it through on_data into env.debug_output,
325
+ # leaving response.body empty (this is the "HTTP 403: " with-nothing-after
326
+ # bug). Fall back to the raw stream buffer so the provider's error text is
327
+ # never lost. Bounded to MAX_ERROR_BODY.
328
+ def error_response_body(env, response)
329
+ raw = response.body.is_a?(Hash) ? response.body.to_json : response.body.to_s
330
+ raw = env.debug_output.string.to_s if raw.blank? && env.debug_output
331
+ raw = raw.to_s
332
+ raw.length > MAX_ERROR_BODY ? "#{raw[0, MAX_ERROR_BODY]}… (truncated)" : raw
333
+ end
334
+
335
+ # Provider response headers are exactly what we need for debugging (rate
336
+ # limits, request ids, cf-ray, etc.), but we still scrub credential-ish
337
+ # ones defensively so nothing sensitive is echoed back into a persisted row.
338
+ def safe_response_headers(response)
339
+ headers = response.respond_to?(:headers) ? response.headers : nil
340
+ return {} if headers.blank?
341
+
342
+ headers.to_h.each_with_object({}) do |(key, value), acc|
343
+ acc[key] = key.to_s.match?(/\A(authorization|x-api-key|api-key)\z/i) ? "[REDACTED]" : value
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,117 @@
1
+ module SolidLoop
2
+ module Middlewares
3
+ class ToolCallXmlParser
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ # 1. Let the downstream pipeline run (ResponseParsing)
10
+ @app.call(env)
11
+
12
+ # 2. On the way back up, ResponseParsing has already created the message.
13
+ # We check if there's a message, if it has content, and NO tool calls.
14
+ msg = env.assistant_message
15
+ return unless msg && msg.content.present? && msg.tool_calls.empty?
16
+
17
+ extracted = extract_from_legacy_xml(msg.content, env.payload[:model])
18
+
19
+ if extracted.any?
20
+ # Revival, tool-call persistence and successor-job dispatch commit
21
+ # atomically (same-DB transactional job backend): a crash or
22
+ # rollback leaves neither a revived loop without jobs nor jobs for
23
+ # rows that never committed.
24
+ env.loop.transaction do
25
+ revived = env.loop.transition_status(
26
+ from: :completed,
27
+ to: :running,
28
+ expected_execution_token: nil,
29
+ execution_token: env.execution_token
30
+ )
31
+ next unless revived
32
+
33
+ # Save raw JSON representation just in case it's needed
34
+ msg.update!(tool_calls_raw: extracted)
35
+
36
+ extracted.each do |tc|
37
+ begin
38
+ args = JSON.parse(tc.dig("function", "arguments") || "{}")
39
+ msg.tool_calls.create!(
40
+ tool_call_id: tc["id"],
41
+ function_name: tc.dig("function", "name"),
42
+ arguments: args
43
+ )
44
+ rescue JSON::ParserError => e
45
+ error_msg = "Invalid JSON in tool call arguments: #{e.message}. Raw: #{tc.dig('function', 'arguments')}"
46
+ Rails.logger.error error_msg
47
+ env.loop.messages.create!(
48
+ role: "tool",
49
+ tool_call_id: tc["id"],
50
+ content: error_msg,
51
+ status: "success"
52
+ )
53
+ end
54
+ end
55
+
56
+ # Explicit ordered scope — never rely on association default order.
57
+ persisted_tool_calls = msg.tool_calls.ordered.to_a
58
+
59
+ # Same emission policy as ResponseParsing: parallel agents dispatch
60
+ # every call at once; sequential agents dispatch only the first and
61
+ # let ResponseCreation chain the rest.
62
+ calls_to_dispatch =
63
+ if env.agent.sequential_tool_calls?
64
+ persisted_tool_calls.first(1)
65
+ else
66
+ persisted_tool_calls
67
+ end
68
+
69
+ calls_to_dispatch.each do |tool_call|
70
+ SolidLoop.enqueue!(SolidLoop::ToolExecutionJob, tool_call.id, env.execution_token)
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def extract_from_legacy_xml(content, model_name)
79
+ tool_calls = []
80
+
81
+ content.scan(/<tool_call>(.*?)<\/tool_call>/m).each do |match|
82
+ begin
83
+ tc_data = JSON.parse(match[0].strip)
84
+ tool_calls << {
85
+ "id" => "xml_#{SecureRandom.hex(4)}",
86
+ "type" => "function",
87
+ "function" => { "name" => tc_data["name"], "arguments" => tc_data["arguments"].to_json }
88
+ }
89
+ rescue JSON::ParserError
90
+ next
91
+ end
92
+ end
93
+
94
+ tool_calls.uniq! { |tc| tc.dig("function", "name") + tc.dig("function", "arguments").to_s }
95
+
96
+ if tool_calls.empty? && model_name&.downcase&.include?("deepseek")
97
+ content.scan(/```json\n(.*?)\n```/m).each do |match|
98
+ begin
99
+ tc_data = JSON.parse(match[0].strip)
100
+ if tc_data["action"] && tc_data["action_input"]
101
+ tool_calls << {
102
+ "id" => "json_#{SecureRandom.hex(4)}",
103
+ "type" => "function",
104
+ "function" => { "name" => tc_data["action"], "arguments" => tc_data["action_input"].to_json }
105
+ }
106
+ end
107
+ rescue JSON::ParserError
108
+ next
109
+ end
110
+ end
111
+ end
112
+
113
+ tool_calls
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,105 @@
1
+ require "event_stream_parser"
2
+
3
+ module SolidLoop
4
+ class SseStreamAggregator
5
+ attr_reader :result
6
+
7
+ def initialize
8
+ @result = {}
9
+ @done = false
10
+ @parser = EventStreamParser::Parser.new
11
+ end
12
+
13
+ def ingest(chunk)
14
+ return if chunk.blank?
15
+
16
+ # The block must be passed to feed in event_stream_parser v1.x
17
+ @parser.feed(chunk) do |type, data, id, reconnection_time|
18
+ process_event(type, data)
19
+ end
20
+ end
21
+
22
+ CONCAT_KEYS = %w[content arguments reasoning_content reasoning thought].freeze
23
+
24
+ def done?
25
+ return true if @done
26
+ choices = @result["choices"] || []
27
+ return false if choices.empty?
28
+
29
+ choices.any? { |c| c["finish_reason"].present? }
30
+ end
31
+
32
+ private
33
+
34
+ def process_event(type, data)
35
+ return if data.blank?
36
+
37
+ if data == "[DONE]"
38
+ @done = true
39
+ return
40
+ end
41
+
42
+ begin
43
+ json_data = JSON.parse(data)
44
+ @result = deep_merge_sse(@result, json_data)
45
+ rescue JSON::ParserError
46
+ # Ignore partial or invalid JSON payloads
47
+ end
48
+ end
49
+
50
+ def deep_merge_sse(target, source)
51
+ source.each do |key, value|
52
+ next if value.nil?
53
+
54
+ target_key = (key == "delta") ? "message" : key
55
+
56
+ if target[target_key].is_a?(Hash) && value.is_a?(Hash)
57
+ deep_merge_sse(target[target_key], value)
58
+ elsif target[target_key].is_a?(Array) && value.is_a?(Array)
59
+ target[target_key] = merge_arrays(target[target_key], value)
60
+ elsif target[target_key].is_a?(String) && value.is_a?(String)
61
+ if CONCAT_KEYS.include?(target_key)
62
+ target[target_key] += value
63
+ else
64
+ target[target_key] = value
65
+ end
66
+ else
67
+ target[target_key] = deep_clone_and_map_sse(value)
68
+ end
69
+ end
70
+ target
71
+ end
72
+
73
+ def deep_clone_and_map_sse(value)
74
+ if value.is_a?(Hash)
75
+ new_hash = {}
76
+ value.each do |k, v|
77
+ nk = (k == "delta") ? "message" : k
78
+ new_hash[nk] = deep_clone_and_map_sse(v)
79
+ end
80
+ new_hash
81
+ elsif value.is_a?(Array)
82
+ value.map { |v| deep_clone_and_map_sse(v) }
83
+ else
84
+ value
85
+ end
86
+ end
87
+
88
+ def merge_arrays(target_arr, source_arr)
89
+ if source_arr.all? { |el| el.is_a?(Hash) && el["index"].is_a?(Integer) }
90
+ source_arr.each do |source_el|
91
+ idx = source_el["index"]
92
+ target_el = target_arr.find { |el| el["index"] == idx }
93
+ if target_el
94
+ deep_merge_sse(target_el, source_el)
95
+ else
96
+ target_arr << deep_clone_and_map_sse(source_el)
97
+ end
98
+ end
99
+ target_arr.sort_by { |el| el["index"] }
100
+ else
101
+ source_arr
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,122 @@
1
+ module SolidLoop
2
+ # ONE shared fenced-transition helper for the tool path's TWO failure
3
+ # reconcilers (the tool-write failure path) so their semantics cannot drift:
4
+ #
5
+ # * `ToolExecutionJob#reconcile_failure!` — a non-graceful StandardError that
6
+ # escaped the pipeline.
7
+ # * `ToolMiddlewares::ErrorHandling#handle_exception` — a graceful error
8
+ # (McpError) the middleware terminalizes in-line.
9
+ #
10
+ # WHY it cannot use `Loop#transition_status`'s block to gate ownership: that
11
+ # method performs the loop `update!` AFTER yielding REGARDLESS — a `next` inside
12
+ # the block aborts only the block, not the status write. So a stale worker whose
13
+ # per-tool lease was rotated away (its lease expired while it was still alive and
14
+ # the reaper's case 2 reclaimed the ToolCall as a fresh lease, siblings unfenced)
15
+ # would still terminalize a loop it no longer owns: `loop.execution_token` is the
16
+ # SHARED generation token and still matches, so the transition fires, the token
17
+ # is cleared, and the CURRENT owner (worker B) is killed. The nested
18
+ # `lease_held_by?` check only skips the ToolCall update, never the loop write.
19
+ #
20
+ # The fix: do the ownership gate OURSELVES under the loop→tool_call row locks and
21
+ # only terminalize when BOTH tokens validate. Three post-error shapes:
22
+ #
23
+ # POST-CLAIM error (`lease_token` present, tool NOT executed): the classic
24
+ # both-token case. Acquire loop → tool_call (the established lock order),
25
+ # validate `loop.execution_token == mine` AND `tool_call.lease_held_by?(mine)`
26
+ # BEFORE either write, then do the ToolCall failure mark AND the loop
27
+ # terminalization in that one locked transaction. A ROTATED lease (A holds
28
+ # LA, reaper reclaimed as LB) → stale/no-op, the loop is NOT failed.
29
+ #
30
+ # EXECUTED continuation, no lease (`lease_token` nil, `executed_at` present):
31
+ # the tool result is already the terminal exactly-once checkpoint; the error
32
+ # is in the continuation (canonical response / successor). Fail the loop under
33
+ # the GENERATION fence only — never dirty the already-committed ToolCall.
34
+ #
35
+ # PRE-LEASE setup error (`lease_token` nil, NOT executed): no tool lease was
36
+ # ever acquired (agent resolution / sequential-mode eval / builder /
37
+ # configure_tool_middlewares). Generation-only fenced failure keeps the
38
+ # job-level reconciliation contract intact. No ToolCall write (none is owned).
39
+ #
40
+ # Returns TRUE when reconciliation was handled here (the caller swallows the
41
+ # error — the failure is durable OR we are a proven-stale no-op that must not
42
+ # retry); FALSE only means "not our concern" (the caller re-raises).
43
+ module ToolFailureReconciler
44
+ module_function
45
+
46
+ def reconcile!(loop_model:, tool_call:, execution_token:, lease_token:, error:)
47
+ # A cancellation fires precisely when this generation already lost the token
48
+ # (pause/stop): the new generation owns both loop and call. Nothing to fail
49
+ # or dirty — swallow (handled) without touching the DB.
50
+ return true if error.is_a?(SolidLoop::CancellationError)
51
+
52
+ error_msg = "#{error.class}: #{error.message}"
53
+ Rails.logger.error(
54
+ "ToolFailureReconciler: #{error_msg}\n#{error.backtrace&.take(10)&.join("\n")}"
55
+ )
56
+
57
+ if lease_token.present?
58
+ reconcile_post_claim(loop_model, tool_call, execution_token, lease_token, error_msg)
59
+ elsif tool_call&.reload&.executed_at?
60
+ # Executed continuation with no lease: terminal checkpoint already written;
61
+ # fail the loop under the generation fence, leave the ToolCall untouched.
62
+ fail_loop_generation_fenced(loop_model, execution_token, error_msg)
63
+ else
64
+ # Pre-lease setup error: generation-only fenced failure.
65
+ fail_loop_generation_fenced(loop_model, execution_token, error_msg)
66
+ end
67
+
68
+ # Reconciliation was handled on the tool path in every branch above — either
69
+ # we terminalized under a validated fence, or we proved ourselves stale and
70
+ # must NOT retry (a stale worker that keeps retrying is worse than a no-op).
71
+ true
72
+ end
73
+
74
+ # POST-CLAIM: the BOTH-token gate, done under the loop→tool_call row locks so
75
+ # `loop.execution_token` and the ToolCall lease columns are frozen between the
76
+ # validation and the writes. If EITHER token fails to match → no-op: we do NOT
77
+ # terminalize a loop we no longer own (the rotated-lease stale-worker case).
78
+ def reconcile_post_claim(loop_model, tool_call, execution_token, lease_token, error_msg)
79
+ loop_model.with_lock do
80
+ # Loop-generation fence (first token) under the loop row lock.
81
+ next unless loop_model.running? && loop_model.execution_token == execution_token
82
+
83
+ tool_call.with_lock do
84
+ now = Time.current
85
+ # Per-tool lease fence (second token) under the tool_call row lock.
86
+ # BOTH must validate BEFORE either write — a rotated/expired lease means
87
+ # a live successor (worker B) owns this call; failing the loop would kill
88
+ # it. Stale → no-op. Note: an EXPIRED lease with no rotation yet (still
89
+ # our generation, no worker B) also no-ops here — an over-TTL worker is
90
+ # by definition no longer authoritative, so its failure surfaces via the
91
+ # reaper's case 2 re-invoke rather than terminalizing the loop directly.
92
+ next unless tool_call.lease_held_by?(lease_token, now: now)
93
+
94
+ # Both tokens validated under the locks: do BOTH writes in this one
95
+ # locked transaction.
96
+ tool_call.update!(is_success: false, error_message: error_msg, lease_token: nil, leased_until: nil)
97
+ loop_model.update!(
98
+ status: :failed,
99
+ error_message: error_msg,
100
+ execution_token: nil,
101
+ lease_expires_at: nil
102
+ )
103
+ end
104
+ end
105
+ end
106
+
107
+ # Generation-only fenced loop failure (pre-lease setup / executed continuation).
108
+ # No ToolCall write — no per-tool lease is owned here. Reuses the loop's status
109
+ # CAS: the block is empty, so `transition_status`'s "update after yield"
110
+ # behaviour is exactly what we want (a bare generation-fenced transition).
111
+ def fail_loop_generation_fenced(loop_model, execution_token, error_msg)
112
+ loop_model.transition_status(
113
+ from: :running,
114
+ to: :failed,
115
+ expected_execution_token: execution_token,
116
+ error_message: error_msg,
117
+ execution_token: nil,
118
+ lease_expires_at: nil
119
+ )
120
+ end
121
+ end
122
+ end