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