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,107 @@
1
+ module SolidLoop
2
+ module Middlewares
3
+ class ErrorHandling
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ GRACEFUL_ERRORS = [
9
+ SolidLoop::CancellationError,
10
+ SolidLoop::LimitExceededError,
11
+ SolidLoop::McpError,
12
+ Faraday::Error,
13
+ JSON::ParserError
14
+ ].freeze
15
+
16
+ def call(env)
17
+ begin
18
+ @app.call(env)
19
+ # Only raise the exception if one of the inner middlewares caught it but didn't re-raise
20
+ # (although inner layers shouldn't rescue unless they fully handle it, some might put it in env.exception)
21
+ raise env.exception if env.exception
22
+ rescue StandardError => e
23
+ raise e unless graceful_error?(e)
24
+ handle_exception(env, e)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def graceful_error?(e)
31
+ GRACEFUL_ERRORS.any? { |klass| e.is_a?(klass) }
32
+ end
33
+
34
+ def handle_exception(env, e)
35
+ error_msg = "#{e.class}: #{e.message}"
36
+ Rails.logger.error "ERROR IN MIDDLEWARE CHAIN: #{error_msg}\n#{e.backtrace.take(10).join("\n")}"
37
+
38
+ if e.is_a?(SolidLoop::CancellationError)
39
+ # Deliberately NOT token-fenced: cancellation fires precisely when
40
+ # this generation lost the token (pause/stop), and it only finalizes
41
+ # the messages this attempt itself created/owns.
42
+ Rails.logger.info "Stream cancelled: #{e.message}"
43
+ env.triggering_message&.update!(status: "success")
44
+
45
+ if env.assistant_message
46
+ # Born-hidden shell protocol: a cancelled/force-stopped partial is
47
+ # NOT a canonical turn — mark it failed + keep it hidden (partial
48
+ # content preserved for audit), never `success`. MessageBuilding's
49
+ # `is_hidden: false` filter then excludes it from a retried turn's
50
+ # payload, so we never resend a partial as history.
51
+ env.assistant_message.update!(status: "failed", is_hidden: true)
52
+ env.assistant_message.broadcast_update
53
+ end
54
+ return
55
+ end
56
+
57
+ if e.is_a?(SolidLoop::LimitExceededError)
58
+ Rails.logger.info "Limit exceeded: #{e.message}"
59
+ env.loop.transition_status(
60
+ from: :running,
61
+ to: :paused,
62
+ expected_execution_token: env.execution_token,
63
+ error_message: error_msg,
64
+ execution_token: nil,
65
+ lease_expires_at: nil
66
+ )
67
+ return
68
+ end
69
+
70
+ Rails.logger.error "LLM Job Failed with Exception: #{error_msg}"
71
+
72
+ # Message states and counters are generation-owned: they run inside
73
+ # the status CAS (under the loop lock), so a stale worker whose token
74
+ # already lost performs zero mutations against the current run.
75
+ transitioned = env.loop.transition_status(
76
+ from: :running,
77
+ to: :failed,
78
+ expected_execution_token: env.execution_token,
79
+ error_message: error_msg,
80
+ execution_token: nil,
81
+ lease_expires_at: nil
82
+ ) do
83
+ env.triggering_message&.update!(status: "failed", error_message: error_msg)
84
+ # Keep any failed processing shell hidden (born-hidden protocol) so a
85
+ # partial never re-enters a retried turn's payload as history.
86
+ env.assistant_message&.update!(status: "failed", error_message: error_msg, is_hidden: true)
87
+
88
+ # Capture duration up to this point
89
+ if env.request_started_at
90
+ duration = (Time.current - env.request_started_at).to_f
91
+ env.loop.increment!(:duration_generation, duration)
92
+ end
93
+ end
94
+
95
+ if transitioned
96
+ env.triggering_message&.broadcast_update
97
+ env.assistant_message&.broadcast_update
98
+ end
99
+
100
+ # We don't re-raise here because ActiveJob would just retry it endlessly.
101
+ # We mark the loop as failed in DB, which is the correct business behavior.
102
+ # But we DO put it in env so outer layers (like Tests) can inspect it
103
+ env.exception = e
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,100 @@
1
+ module SolidLoop
2
+ module Middlewares
3
+ class EventLogging
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ headers = { "Content-Type" => "application/json" }
10
+ headers["Authorization"] = "Bearer #{env.model_config[:api_token]}" if env.model_config[:api_token].present?
11
+
12
+ safe_headers = headers.dup
13
+ safe_headers["Authorization"] = "[REDACTED]" if safe_headers.key?("Authorization")
14
+
15
+ env.event = SolidLoop::Event.new(
16
+ eventable: env.triggering_message || env.loop,
17
+ loop_id: env.loop.id,
18
+ message_id: env.triggering_message&.id,
19
+ name: "llm_completion",
20
+ http_method: "POST",
21
+ url: env.model_config[:base_url].to_s,
22
+ request_data: env.payload,
23
+ headers: safe_headers,
24
+ log: "Requesting LLM (#{env.agent.streaming? ? 'Streaming' : 'Sync'})..."
25
+ )
26
+
27
+ # We save before request if configured for slow models debugging
28
+ env.event.save! if SolidLoop::LlmCompletionJob::EVENTS_SAVE_BEFORE_REQUEST
29
+
30
+ # Set up log buffers for the network layer to use
31
+ env.log_buffer ||= StringIO.new
32
+ env.debug_output ||= StringIO.new
33
+
34
+ begin
35
+ env.mark_request_started!
36
+ @app.call(env)
37
+ ensure
38
+ finalize_event(env)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def finalize_event(env)
45
+ return unless env.event
46
+
47
+ eventable = env.assistant_message || env.triggering_message || env.loop
48
+
49
+ detailed_log = env.log_buffer.string
50
+ detailed_log += "--- Stream/Raw Data ---\n#{env.debug_output.string}\n"
51
+ detailed_log = redact_credentials(detailed_log)
52
+
53
+ # $! surfaces a connection/timeout error that is still propagating up to
54
+ # ErrorHandling (which sets env.exception only afterwards), so the Event
55
+ # captures the raw transport failure too — not just LLM HTTP errors.
56
+ raised = env.exception || $!
57
+ error_message = raised&.message
58
+ error_message = "#{raised.class}: #{error_message}" if raised && !env.exception
59
+ error_message = redact_credentials(error_message) if error_message
60
+
61
+ response_data =
62
+ if env.error_status
63
+ # Failing (non-2xx) response: persist the provider's status, headers
64
+ # and (redacted) body so the failure is debuggable in the admin.
65
+ # This is the wire-log gap for LLM HTTP errors — mirrors what the MCP
66
+ # path records for its request/response envelopes.
67
+ {
68
+ http_status: env.error_status,
69
+ response_headers: env.error_headers || {},
70
+ response_body: redact_credentials(env.error_body.to_s)
71
+ }
72
+ elsif env.agent.streaming?
73
+ env.aggregator&.result || {}
74
+ else
75
+ begin
76
+ env.response ? JSON.parse(env.response.body) : {}
77
+ rescue => _
78
+ { error: "Failed to parse body" }
79
+ end
80
+ end
81
+
82
+ env.event.assign_attributes(
83
+ eventable: eventable,
84
+ message_id: eventable.is_a?(SolidLoop::Message) ? eventable.id : env.event.message_id,
85
+ url: redact_credentials(env.request_url || env.event.url),
86
+ response_data: response_data.merge(error: error_message).compact,
87
+ log: detailed_log,
88
+ duration: env.duration || (Time.current - env.request_started_at).to_f
89
+ )
90
+ env.event.save!
91
+ end
92
+
93
+ def redact_credentials(value)
94
+ value.to_s
95
+ .gsub(/(Authorization|x-api-key|api-key):[^\r\n]*/i, "\\1: [REDACTED]")
96
+ .gsub(/([?&](?:key|api_key|api-key)=)[^&\s]*/i, "\\1[REDACTED]")
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,92 @@
1
+ module SolidLoop
2
+ module Middlewares
3
+ class MessageBuilding
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ build_messages(env)
10
+ build_payload(env)
11
+
12
+ env.triggering_message&.update!(status: "processing")
13
+
14
+ @app.call(env)
15
+ end
16
+
17
+ private
18
+
19
+ def build_messages(env)
20
+ env.messages = []
21
+ env.triggering_message = nil
22
+ env.messages_text_for_token_estimation = ""
23
+
24
+ # Turn boundary: the previous turn's tool results are all written and no
25
+ # half-open batch exists yet, so this is the one safe point to fold any
26
+ # user-enqueued steering messages into the conversation. Materialization
27
+ # un-hides them (so they appear below) and marks them delivered (so a turn
28
+ # retry never re-delivers). It must precede the assembly query so the
29
+ # newly un-hidden rows are included in THIS turn.
30
+ env.loop.drain_pending_steering!
31
+
32
+ # Order by COALESCE(conversation_order, id): ordinary messages (NULL) keep
33
+ # id order; a DELIVERED steering message uses its delivery-time
34
+ # conversation_order so it sorts after any tool batch it was enqueued amid,
35
+ # preserving the assistant(tool_calls) -> tool results pairing invariant.
36
+ all_messages = env.loop.messages.where(is_hidden: false)
37
+ .order(Arel.sql("COALESCE(conversation_order, id)")).includes(:tool_calls)
38
+
39
+ # Build tool_call_id -> function_name lookup for tool response messages
40
+ tool_call_name_index = all_messages
41
+ .flat_map { |m| m.tool_calls.sort_by(&:id) }
42
+ .each_with_object({}) { |tc, h| h[tc.tool_call_id] = tc.function_name }
43
+
44
+ all_messages.each do |msg|
45
+ m = { role: msg.role, content: msg.content }
46
+
47
+ env.messages_text_for_token_estimation += msg.content.to_s + msg.reasoning_content.to_s
48
+
49
+ if msg.role == "assistant" && msg.reasoning_content.present?
50
+ # Pass raw reasoning data down to the network layer (adapter)
51
+ # We use internal keys to prevent polluting the standard OpenAI payload
52
+ # The adapter will process and remove these based on its strategies.
53
+ m[:_sl_reasoning] = msg.reasoning_content
54
+
55
+ if msg.metadata && msg.metadata["thought_signature"]
56
+ m[:_sl_signature] = msg.metadata["thought_signature"]
57
+ end
58
+ end
59
+
60
+ if msg.role == "tool"
61
+ m[:tool_call_id] = msg.tool_call_id
62
+ m[:name] = tool_call_name_index[msg.tool_call_id]
63
+ env.triggering_message = msg
64
+ elsif msg.role == "user"
65
+ env.triggering_message = msg
66
+ elsif msg.role == "assistant" && msg.tool_calls.any?
67
+ m[:tool_calls] = msg.tool_calls.sort_by(&:id).map do |atc|
68
+ {
69
+ id: atc.tool_call_id,
70
+ type: "function",
71
+ function: { name: atc.function_name, arguments: atc.arguments.to_json }
72
+ }
73
+ end
74
+ end
75
+
76
+ env.messages << m
77
+ end
78
+ end
79
+
80
+ def build_payload(env)
81
+ tools = env.loop.mcp_tools.ordered.map(&:to_tool_spec)
82
+
83
+ env.payload = {
84
+ model: env.model_config[:model],
85
+ messages: env.messages,
86
+ stream: !!env.agent.streaming?
87
+ }
88
+ env.payload[:tools] = tools if tools.any?
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ module Middlewares
5
+ class NetworkCalling
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ adapter_class = SolidLoop::Adapters::Native
12
+
13
+ # Determine the dialect from the agent
14
+ dialect_name = env.agent.llm_dialect_name.to_s
15
+ dialect =
16
+ case dialect_name
17
+ when "gemini" then SolidLoop::Dialects::Gemini.new
18
+ when "anthropic" then SolidLoop::Dialects::Anthropic.new
19
+ else SolidLoop::Dialects::OpenAi.new
20
+ end
21
+
22
+ adapter = adapter_class.new(
23
+ base_url: env.model_config[:base_url],
24
+ api_token: env.model_config[:api_token],
25
+ model_name: env.model_config[:llm_model_name],
26
+ payload: env.payload,
27
+ streaming: !!env.agent.streaming?,
28
+ reasoning_strategies: env.agent.reasoning_strategies,
29
+ dialect: dialect,
30
+ fallback_messages_text: env.messages_text_for_token_estimation,
31
+ read_timeout: env.model_config[:read_timeout],
32
+ cancellation_check: -> { env.loop_active_fresh? }
33
+ )
34
+
35
+ if env.agent.streaming?
36
+ # Born hidden: a mid-stream partial is not canonical
37
+ # history. The ONLY path that flips it visible is the canonical LLM
38
+ # commit txn in ResponseParsing (status: success + is_hidden: false).
39
+ # Reclaim/pause/stop/error keep it hidden and mark it failed, so a
40
+ # retried turn never resends a partial (MessageBuilding filters
41
+ # is_hidden: false). Hosts opt into showing live processing shells.
42
+ env.assistant_message = env.loop.messages.create!(
43
+ role: "assistant",
44
+ status: "processing",
45
+ content: "",
46
+ reasoning_content: "",
47
+ is_hidden: true
48
+ )
49
+
50
+ adapter.on_chunk_proc = proc do |content, reasoning, tool_calls, metrics|
51
+ # Business logic: Check if we should continue
52
+ if !env.loop_active_fresh?
53
+ Rails.logger.warn "Loop #{env.loop.id} is no longer active. Cancelling stream from middleware."
54
+ raise SolidLoop::CancellationError, "Loop status changed to #{env.loop.status}"
55
+ end
56
+
57
+ env.assistant_message.trigger_content_append(
58
+ content,
59
+ reasoning,
60
+ tool_calls,
61
+ **metrics.to_h
62
+ )
63
+ end
64
+ end
65
+
66
+ env.request_url = adapter.build_url
67
+
68
+ # Execute and capture results back into env
69
+ result = adapter.call
70
+
71
+ env.response = result[:response]
72
+ env.normalized_data = result[:normalized_data]
73
+ env.duration = result[:duration]
74
+ env.aggregator = result[:aggregator]
75
+
76
+ # Merge logs back to environment buffers
77
+ env.log_buffer.write(result[:log]) if env.log_buffer
78
+ env.debug_output.write(result[:debug_log]) if env.debug_output
79
+
80
+ @app.call(env)
81
+ end
82
+ end
83
+ end
84
+ end