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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ module Dialects
5
+ class OpenAi
6
+ def completion_url(base_url)
7
+ base_url.to_s.gsub(/\/+$/, "") + "/v1/chat/completions"
8
+ end
9
+
10
+ def auth_headers(api_token)
11
+ api_token.present? ? { "Authorization" => "Bearer #{api_token}" } : {}
12
+ end
13
+
14
+ def apply_reasoning_strategies!(messages, strategies)
15
+ return unless messages.is_a?(Array)
16
+
17
+ # OpenAI filter: remove Gemini-specific strategies, allow XML and string fields
18
+ filtered = strategies.reject { |s| s == :gemini_signed }
19
+
20
+ messages.each do |msg|
21
+ SolidLoop::Dialects::ReasoningPacker.pack(msg, filtered)
22
+ end
23
+ end
24
+
25
+ def extract_message_data(data)
26
+ choice = data.dig("choices", 0, "message") || data.dig("choices", 0, "delta") || {}
27
+ {
28
+ content: choice["content"],
29
+ reasoning: choice["reasoning_content"] || choice["reasoning"] || choice["thought"],
30
+ tool_calls: choice["tool_calls"] || []
31
+ }
32
+ end
33
+
34
+ def normalize_response(data, fallback_text: "", fallback_messages_text: "")
35
+ choice = data.dig("choices", 0, "message") || data.dig("choices", 0, "delta") || {}
36
+
37
+ usage_data =
38
+ if data["usage"].is_a?(Hash) && data["usage"]["total_tokens"].to_i > 0
39
+ SolidLoop::LlmUsageParser::Openai.call(data["usage"])
40
+ elsif data["timings"].is_a?(Hash)
41
+ SolidLoop::LlmUsageParser::Llama.call(data["timings"])
42
+ else
43
+ SolidLoop::LlmUsageParser.estimate(fallback_text, fallback_messages_text)
44
+ end
45
+
46
+ reasoning_field =
47
+ if choice["reasoning_content"] then "reasoning_content"
48
+ elsif choice["reasoning"] then "reasoning"
49
+ elsif choice["thought"] then "thought"
50
+ end
51
+
52
+ {
53
+ content: choice["content"],
54
+ reasoning: choice[reasoning_field],
55
+ tool_calls: choice["tool_calls"] || [],
56
+ usage: usage_data,
57
+ metadata: { "reasoning_field" => reasoning_field, "model" => data["model"].presence }.compact
58
+ }
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ module Dialects
5
+ class ReasoningPacker
6
+ def self.pack(msg, strategies)
7
+ reasoning = msg.delete(:_sl_reasoning)
8
+ signature = msg.delete(:_sl_signature)
9
+ return if reasoning.blank?
10
+
11
+ # 1. Determine if we have any native/specific strategies present in the array
12
+ # Excluding XML and string-based fields
13
+ has_native_strategy = strategies.any? { |s| s == :gemini_signed }
14
+
15
+ strategies.each do |strategy|
16
+ case strategy
17
+ when :xml
18
+ # Apply universal fallback ONLY if no native strategy is active
19
+ apply_xml!(msg, reasoning) unless has_native_strategy
20
+ when :xml!
21
+ # FORCE XML wrapping regardless of other strategies
22
+ apply_xml!(msg, reasoning)
23
+ when :gemini_signed
24
+ msg[:thought] = reasoning
25
+ msg[:thought_signature] = signature if signature.present?
26
+ when String
27
+ msg[strategy.to_sym] = reasoning
28
+ end
29
+ end
30
+ end
31
+
32
+ def self.apply_xml!(msg, reasoning)
33
+ msg[:content] = "<think>\n#{reasoning}\n</think>\n#{msg[:content]}"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ class LlmUsageParser
5
+ class Anthropic
6
+ def self.call(usage)
7
+ input = usage["input_tokens"].to_i
8
+ output = usage["output_tokens"].to_i
9
+ cached = usage.dig("cache_read_input_tokens").to_i
10
+
11
+ {
12
+ tokens_prompt: input,
13
+ tokens_completion: output,
14
+ tokens_total: input + output,
15
+ tokens_prompt_cached: cached,
16
+ cost: 0.0,
17
+ tps: 0.0
18
+ }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ class LlmUsageParser
5
+ class Gemini
6
+ def self.call(usage)
7
+ {
8
+ tokens_prompt: usage["promptTokenCount"].to_i,
9
+ tokens_completion: usage["candidatesTokenCount"].to_i,
10
+ tokens_total: usage["totalTokenCount"].to_i,
11
+ tokens_prompt_cached: 0,
12
+ cost: usage["cost"].to_f, # Support if they add it
13
+ tps: 0.0
14
+ }
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ module SolidLoop
2
+ class LlmUsageParser
3
+ class Llama
4
+ def self.call(timings)
5
+ {
6
+ tokens_prompt: timings["prompt_n"].to_i,
7
+ tokens_completion: timings["predicted_n"].to_i,
8
+ tokens_total: timings["prompt_n"].to_i + timings["predicted_n"].to_i,
9
+ tokens_prompt_cached: timings["cache_n"].to_i,
10
+ tps: timings["predicted_per_second"].to_f.round(2)
11
+ }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ class LlmUsageParser
5
+ class Openai
6
+ def self.call(usage)
7
+ {
8
+ tokens_prompt: usage["prompt_tokens"].to_i,
9
+ tokens_completion: usage["completion_tokens"].to_i,
10
+ tokens_total: usage["total_tokens"].to_i,
11
+ tokens_prompt_cached: usage.dig("prompt_tokens_details", "cached_tokens").to_i,
12
+ cost: usage["cost"].to_f, # Directly from provider
13
+ tps: 0.0
14
+ }
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ class LlmUsageParser
5
+ def self.estimate(text, messages_text)
6
+ tokens_completion = text.to_s.length / 4
7
+ tokens_prompt = messages_text.to_s.length / 4
8
+
9
+ {
10
+ tokens_prompt: tokens_prompt,
11
+ tokens_completion: tokens_completion,
12
+ tokens_total: tokens_prompt + tokens_completion,
13
+ tokens_prompt_cached: 0,
14
+ cost: 0.0,
15
+ tps: 0.0
16
+ }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,205 @@
1
+ module SolidLoop
2
+ class McpSessionInitializer
3
+ def self.call(loop, agent, execution_token: :any)
4
+ new(loop, agent, execution_token: execution_token).call
5
+ end
6
+
7
+ def initialize(loop, agent, execution_token: :any)
8
+ @loop = loop
9
+ @agent = agent
10
+ @execution_token = execution_token
11
+ end
12
+
13
+ def call
14
+ @agent.mcps.each do |mcp_config|
15
+ process_mcp(mcp_config)
16
+ end
17
+
18
+ validate_tool_name_collisions!
19
+ end
20
+
21
+ private
22
+
23
+ # Session/tool sync writes are generation-owned: a stale worker (token
24
+ # moved on via pause/stop/resume) must not mutate the loop's MCP state.
25
+ # Network calls cannot run under a row lock, so this is a conditional
26
+ # re-check before each write phase instead.
27
+ def stale_generation?
28
+ return false if @execution_token == :any
29
+
30
+ @loop.reload
31
+ !(@loop.running? && @loop.execution_token == @execution_token)
32
+ end
33
+
34
+ def process_mcp(mcp_config)
35
+ return if stale_generation?
36
+
37
+ name = mcp_config[:name].to_s
38
+ session = @loop.mcp_sessions.find_by(mcp_name: name)
39
+ refresh_needed = @agent.refresh_mcp_tools?
40
+ return if session && !refresh_needed
41
+
42
+ context = Mcp::CallContext.new(agent: @agent, loop: @loop, principal: @agent.mcp_principal)
43
+ client = Mcp::ClientFactory.client_for(mcp_config, context: context)
44
+ kind = Mcp::ClientFactory.transport_kind(mcp_config)
45
+
46
+ if session.nil?
47
+ sid = initialize_session(client, mcp_config)
48
+ return if stale_generation?
49
+
50
+ session = @loop.mcp_sessions.create!(mcp_name: name, session_id: sid, transport_kind: kind,
51
+ metadata: { "capabilities" => client.server_capabilities })
52
+ refresh_needed = true
53
+ elsif session.transport_kind != kind
54
+ # The entry form changed (e.g. url: -> toolset:) — keep the admin UI honest.
55
+ session.update!(transport_kind: kind)
56
+ end
57
+
58
+ refresh_tools(session, mcp_config, client, name) if refresh_needed
59
+ end
60
+
61
+ def initialize_session(client, mcp_config)
62
+ with_mcp_event("mcp_initialize", client, mcp_config, request_fallback: {}) do
63
+ client.initialize_session
64
+ end
65
+ end
66
+
67
+ def refresh_tools(session, mcp_config, client, name)
68
+ tools = with_mcp_event("mcp_list_tools", client, mcp_config, request_fallback: { session_id: session.session_id }) do
69
+ client.list_tools(session.session_id)
70
+ end
71
+
72
+ return if stale_generation?
73
+
74
+ sync_tools(session, mcp_config, tools)
75
+ validate_required_tools(session, mcp_config, tools, name)
76
+ discover_prompts_and_resources(session, mcp_config, client)
77
+
78
+ @agent.on_mcp_session_initialized(session) if @agent.respond_to?(:on_mcp_session_initialized)
79
+ end
80
+
81
+ # Snapshots prompts/resources lists into session metadata for the admin UI
82
+ # when the server advertises those capabilities. Best-effort: a failing
83
+ # prompts/resources endpoint must not take tool availability down with it
84
+ # (the attempt still leaves an Event behind).
85
+ def discover_prompts_and_resources(session, mcp_config, client)
86
+ capabilities = session.metadata&.fetch("capabilities", {}) || {}
87
+ discovered = {}
88
+
89
+ { "prompts" => :list_prompts, "resources" => :list_resources }.each do |capability, list_method|
90
+ next unless capabilities.key?(capability)
91
+
92
+ begin
93
+ discovered[capability] = with_mcp_event("mcp_#{list_method}", client, mcp_config,
94
+ request_fallback: { session_id: session.session_id }) do
95
+ client.public_send(list_method, session.session_id)
96
+ end
97
+ rescue SolidLoop::McpError
98
+ nil
99
+ end
100
+ end
101
+
102
+ return if stale_generation?
103
+
104
+ session.update!(metadata: (session.metadata || {}).merge(discovered)) if discovered.any?
105
+ end
106
+
107
+ def sync_tools(session, mcp_config, tools)
108
+ whitelist = mcp_config[:tools]&.map(&:to_s)
109
+
110
+ server_tools = tools.filter_map do |t|
111
+ next if whitelist && !whitelist.include?(t["name"])
112
+ t
113
+ end
114
+
115
+ # Tools are advertised to the LLM under their prefixed name; the prefix
116
+ # is stripped again before tools/call reaches the server.
117
+ advertised = server_tools.index_by { |t| advertised_name(t["name"], mcp_config) }
118
+ existing = session.mcp_tools.index_by(&:name)
119
+
120
+ # Destroy tools no longer returned by the server
121
+ (existing.keys - advertised.keys).each { |n| existing[n].destroy! }
122
+
123
+ # Update existing or create new
124
+ advertised.each do |adv_name, t|
125
+ attrs = { description: t["description"], input_schema: t["inputSchema"] }
126
+ if existing[adv_name]
127
+ existing[adv_name].update!(attrs)
128
+ else
129
+ session.mcp_tools.create!(name: adv_name, **attrs)
130
+ end
131
+ end
132
+ end
133
+
134
+ def validate_required_tools(session, mcp_config, tools, name)
135
+ required = (mcp_config[:required_tools] || mcp_config[:tools])&.map(&:to_s)
136
+ return unless required&.any?
137
+
138
+ synced = session.mcp_tools.pluck(:name)
139
+ missing = required.reject { |n| synced.include?(advertised_name(n, mcp_config)) }
140
+ return unless missing.any?
141
+
142
+ error_msg = "Stop: Required MCP tools missing for #{name}: #{missing.join(', ')}. " \
143
+ "Tools returned from server: #{tools.map { |t| t['name'] }.join(', ')}"
144
+ fail_loop(error_msg)
145
+ raise error_msg
146
+ end
147
+
148
+ def validate_tool_name_collisions!
149
+ pairs = @loop.mcp_tools.joins(:mcp_session)
150
+ .pluck("solid_loop_mcp_tools.name", "solid_loop_mcp_sessions.mcp_name")
151
+ collisions = pairs.group_by(&:first).select { |_, rows| rows.size > 1 }
152
+ return if collisions.empty?
153
+
154
+ details = collisions.map { |tool, rows| "'#{tool}' (servers: #{rows.map(&:last).sort.join(', ')})" }
155
+ error_msg = "Stop: MCP tool name collision across servers: #{details.join('; ')}. " \
156
+ "Add prefix: to one of the servers to disambiguate."
157
+ fail_loop(error_msg)
158
+ raise SolidLoop::McpError, error_msg
159
+ end
160
+
161
+ def advertised_name(tool_name, mcp_config)
162
+ prefix = mcp_config[:prefix]
163
+ prefix ? "#{prefix}__#{tool_name}" : tool_name
164
+ end
165
+
166
+ def fail_loop(error_msg)
167
+ # Cleanup 4 — every non-`running` target must clear the LLM lease
168
+ # (lease_expires_at) so the DB CHECK holds and the reaper doesn't re-select
169
+ # a terminal row. Failing during MCP init happens while the loop is still
170
+ # `running` + leased, so NULL the lease with the transition.
171
+ @loop.transition_status(
172
+ from: SolidLoop::Loop::ACTIVE_STATUSES,
173
+ to: :failed,
174
+ expected_execution_token: @execution_token,
175
+ error_message: error_msg,
176
+ execution_token: nil,
177
+ lease_expires_at: nil
178
+ )
179
+ end
180
+
181
+ def with_mcp_event(event_name, client, mcp_config, request_fallback:)
182
+ start_time = Time.current
183
+ yield
184
+ ensure
185
+ record_event(event_name, client, mcp_config, start_time, $!, request_fallback)
186
+ end
187
+
188
+ def record_event(event_name, client, mcp_config, start_time, exception, request_fallback)
189
+ duration = Time.current - start_time
190
+ result = client.last_result
191
+
192
+ SolidLoop::Event.create!(
193
+ loop_id: @loop.id,
194
+ eventable: @loop,
195
+ name: event_name,
196
+ http_method: "POST",
197
+ url: mcp_config[:url] || "mcp://#{mcp_config[:name]}",
198
+ request_data: result ? result.raw_request : request_fallback,
199
+ response_data: result ? result.raw_response || {} : { error: exception&.message }.compact,
200
+ duration: duration,
201
+ log: client.wire_log
202
+ )
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,153 @@
1
+ module SolidLoop
2
+ class McpToolExecutionService
3
+ attr_reader :mcp_tool, :agent, :tool_call, :mcp_config, :session, :client, :retries_count
4
+
5
+ def self.call(mcp_tool:, agent:, arguments:, tool_call: nil)
6
+ new(mcp_tool, agent, tool_call: tool_call).call(arguments)
7
+ end
8
+
9
+ def initialize(mcp_tool, agent, tool_call: nil)
10
+ @mcp_tool = mcp_tool
11
+ @agent = agent
12
+ @tool_call = tool_call
13
+ @session = mcp_tool.mcp_session
14
+ @mcp_config = agent.mcps.find { |c| c[:name].to_s == @session&.mcp_name }
15
+ @retries_count = 0
16
+ end
17
+
18
+ def call(arguments)
19
+ @start_time = Time.current
20
+ validate_context!
21
+ @client = create_client
22
+ execute_with_retries(arguments)
23
+ rescue => e
24
+ build_error_result(e)
25
+ end
26
+
27
+ private
28
+
29
+ def validate_context!
30
+ unless mcp_config && session
31
+ raise SolidLoop::McpError, "Error: MCP configuration or session for '#{session&.mcp_name}' missing."
32
+ end
33
+ end
34
+
35
+ def create_client
36
+ context = Mcp::CallContext.new(
37
+ agent: agent,
38
+ loop: session.loop,
39
+ principal: agent.mcp_principal,
40
+ idempotency_key: tool_call&.idempotency_key
41
+ )
42
+ Mcp::ClientFactory.client_for(mcp_config, context: context)
43
+ end
44
+
45
+ def execute_with_retries(arguments)
46
+ begin
47
+ perform_invocation(arguments)
48
+ rescue SolidLoop::McpSessionInvalidError => e
49
+ # Some servers bind real state to the session id (sandboxes, workspaces);
50
+ # silently swapping such a session mid-loop would destroy that state.
51
+ if session_recovery == :fail
52
+ raise SolidLoop::McpSessionInvalidError,
53
+ "#{e.message}. session_recovery is :fail for '#{session.mcp_name}' — " \
54
+ "not re-initializing; recreate the session explicitly."
55
+ end
56
+
57
+ if retries_count.zero?
58
+ recover_session(failed_session_id: session.session_id)
59
+ @retries_count += 1
60
+ retry
61
+ else
62
+ raise e
63
+ end
64
+ end
65
+ end
66
+
67
+ # Value is validated eagerly in Mcp::ClientFactory.
68
+ def session_recovery
69
+ (mcp_config[:session_recovery] || :auto).to_sym
70
+ end
71
+
72
+ def perform_invocation(arguments)
73
+ result_text = client.call_tool(session.session_id, server_tool_name, arguments, meta: call_meta)
74
+ duration = Time.current - @start_time
75
+ finalize_result(!client.last_tool_error?, result_text, duration)
76
+ end
77
+
78
+ # Stable per-ToolCall idempotency key on the wire (MCP `_meta`), so remote
79
+ # servers can dedupe an invocation that is re-delivered after a crash
80
+ # between their side effect and SolidLoop's executed_at checkpoint.
81
+ def call_meta
82
+ return nil unless tool_call
83
+
84
+ { "solidloop/idempotencyKey" => tool_call.idempotency_key }
85
+ end
86
+
87
+ # Tools are advertised to the LLM under their prefixed name; the server
88
+ # only knows the unprefixed one.
89
+ def server_tool_name
90
+ prefix = mcp_config[:prefix]
91
+ prefix ? mcp_tool.name.delete_prefix("#{prefix}__") : mcp_tool.name
92
+ end
93
+
94
+ # Serialize recovery on the McpSession row so two tools that both saw the
95
+ # SAME session go invalid don't each mint a replacement and overwrite each
96
+ # other (only the last id would survive; calls would run against different,
97
+ # orphaned replacements). Under the row lock the first caller replaces the
98
+ # failed id; a later caller finds the id already moved on and ADOPTS the
99
+ # winner instead of forking a second replacement. The row lock is DELIBERATELY
100
+ # held across `client.initialize_session` (a network call) so recovery is
101
+ # truly serialized; it is bounded by the MCP client's Faraday timeout (default
102
+ # 60s) and contention is limited to the two workers sharing this one session row.
103
+ def recover_session(failed_session_id:)
104
+ session.with_lock do
105
+ if session.session_id != failed_session_id
106
+ # Another thread already recovered this exact failed session — adopt
107
+ # its replacement rather than initializing (and overwriting with) our
108
+ # own. `session` is reloaded by with_lock, so session_id is current.
109
+ next
110
+ end
111
+
112
+ sid = client.initialize_session
113
+ session.update!(session_id: sid)
114
+ end
115
+ end
116
+
117
+ def build_error_result(exception)
118
+ duration = Time.current - @start_time
119
+ result_text = exception.is_a?(SolidLoop::McpError) ? exception.message : "Exception: #{exception.message}"
120
+ finalize_result(false, result_text, duration, exception: exception)
121
+ end
122
+
123
+ def finalize_result(success, result_text, duration, exception: nil)
124
+ result = client&.last_result
125
+ # We attempt to capture logs/data even on failure if client was initialized
126
+ request_data = capture_request_data(result)
127
+ response_data = capture_response_data(result, result_text, exception)
128
+ log_text = client&.wire_log || "No response captured"
129
+
130
+ {
131
+ success: success,
132
+ result: result_text,
133
+ duration: duration,
134
+ log: log_text,
135
+ request_data: request_data,
136
+ response_data: response_data,
137
+ retries_count: retries_count
138
+ }
139
+ end
140
+
141
+ def capture_request_data(result)
142
+ result ? result.raw_request : {}
143
+ end
144
+
145
+ def capture_response_data(result, result_text, exception)
146
+ if result
147
+ result.raw_response || {}
148
+ else
149
+ { result: result_text, error: exception&.message }.compact
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,37 @@
1
+ module SolidLoop
2
+ module Middlewares
3
+ class AgentInitialization
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ env.agent = env.loop.agent
10
+ env.model_config = env.agent.llm_provider
11
+
12
+ check_limits!(env.loop, env.agent)
13
+
14
+ SolidLoop::McpSessionInitializer.call(env.loop, env.agent, execution_token: env.execution_token)
15
+
16
+ @app.call(env)
17
+ end
18
+
19
+ private
20
+
21
+ def check_limits!(loop, agent)
22
+ checks = {
23
+ max_steps: loop.step_count >= agent.max_steps,
24
+ max_total_tokens: loop.tokens_total >= agent.max_total_tokens,
25
+ max_cost: loop.cost >= agent.max_cost.to_d,
26
+ max_duration: loop.created_at <= agent.max_duration.ago
27
+ }
28
+
29
+ violated = checks.select { |_, hit| hit }
30
+ return if violated.empty?
31
+
32
+ reason = violated.keys.map(&:to_s).join(", ")
33
+ raise SolidLoop::LimitExceededError, "Safety limit hit: #{reason}"
34
+ end
35
+ end
36
+ end
37
+ end