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