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,190 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
class Base
|
|
3
|
+
class << self
|
|
4
|
+
attr_accessor :llm_provider
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
attr_reader :loop_record, :subject
|
|
8
|
+
|
|
9
|
+
def initialize(loop_record)
|
|
10
|
+
@loop_record = loop_record
|
|
11
|
+
@subject = loop_record.subject
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.create!(subject:, message:, system_prompt: nil, **state)
|
|
15
|
+
loop_record = SolidLoop::Loop.create!(
|
|
16
|
+
subject: subject,
|
|
17
|
+
agent_class_name: name,
|
|
18
|
+
state: state
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
agent_instance = new(loop_record)
|
|
22
|
+
|
|
23
|
+
system_prompt_content = system_prompt || begin
|
|
24
|
+
agent_instance.system_prompt
|
|
25
|
+
rescue NotImplementedError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if system_prompt_content.present?
|
|
30
|
+
loop_record.messages.create!(role: "system", content: system_prompt_content)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
loop_record.messages.create!(role: "user", content: message)
|
|
34
|
+
|
|
35
|
+
loop_record
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.start!(subject:, message:, system_prompt: nil, **state)
|
|
39
|
+
loop_record = create!(subject: subject, message: message, system_prompt: system_prompt, **state)
|
|
40
|
+
|
|
41
|
+
# State advancement and dispatch commit atomically (same-database
|
|
42
|
+
# transactional job backend): no queued loop without its job.
|
|
43
|
+
loop_record.transaction do
|
|
44
|
+
loop_record.update!(status: "queued")
|
|
45
|
+
SolidLoop.enqueue!(LlmCompletionJob, loop_record.id)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
loop_record
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def mcps
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def configure_llm_middlewares(builder)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def configure_tool_middlewares(builder)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def refresh_mcp_tools?
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def streaming?
|
|
66
|
+
true # Default backwards compatibility
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# When false (default), all of a turn's tool calls are enqueued at once and
|
|
70
|
+
# run in parallel across workers. When true, they run strictly one-at-a-time
|
|
71
|
+
# in model order (the next call is enqueued only after the previous result
|
|
72
|
+
# lands), and the execution-boundary guard rejects out-of-order jobs.
|
|
73
|
+
#
|
|
74
|
+
# HITL / per-tool approval gating is cleanest with `true`: a single in-flight
|
|
75
|
+
# tool at a time means an approval decision has one unambiguous step to gate.
|
|
76
|
+
def sequential_tool_calls?
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def llm_adapter_name
|
|
81
|
+
:native
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def llm_dialect_name
|
|
85
|
+
:open_ai
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def reasoning_strategies
|
|
89
|
+
# Default to the most robust method for returning thoughts: XML wrapping
|
|
90
|
+
[ :xml ]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def mcp_principal
|
|
94
|
+
subject
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def max_tokens
|
|
98
|
+
16 * 1024 # Default
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def max_steps
|
|
102
|
+
100
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def max_total_tokens
|
|
106
|
+
1_000_000
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def max_cost
|
|
110
|
+
10.0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def max_duration
|
|
114
|
+
2.hours
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def system_prompt
|
|
118
|
+
raise NotImplementedError, "You must implement #system_prompt in #{self.class.name}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def on_message_created(message)
|
|
122
|
+
# Hook called whenever a new message is created in the loop.
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def on_mcp_session_initialized(mcp_session)
|
|
126
|
+
# Hook called when an MCP session is established for the agent.
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def pause!
|
|
130
|
+
# Cleanup 6 — pausing synchronously fails+hides any orphaned `processing`
|
|
131
|
+
# assistant shell (born-hidden shell protocol) inside the same transition
|
|
132
|
+
# txn, so no paused loop retains a hidden `processing` shell. The block runs
|
|
133
|
+
# under the loop lock.
|
|
134
|
+
loop_record.transition_status(
|
|
135
|
+
from: SolidLoop::Loop::ACTIVE_STATUSES,
|
|
136
|
+
to: :paused,
|
|
137
|
+
execution_token: nil,
|
|
138
|
+
lease_expires_at: nil
|
|
139
|
+
) do
|
|
140
|
+
loop_record.messages.where(role: "assistant", status: "processing").find_each do |shell|
|
|
141
|
+
shell.update!(status: "failed", is_hidden: true)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def resume!
|
|
147
|
+
# State advancement and dispatch commit atomically inside the lock's
|
|
148
|
+
# transaction (same-database transactional job backend): a crash leaves
|
|
149
|
+
# neither a running loop without jobs nor jobs for a rolled-back resume.
|
|
150
|
+
loop_record.with_lock do
|
|
151
|
+
raise StandardError, "Unfreeze in admin UI before resuming this loop" if loop_record.admin_frozen?
|
|
152
|
+
return false unless loop_record.init? || loop_record.paused? || loop_record.failed? || loop_record.completed?
|
|
153
|
+
|
|
154
|
+
pending_tool_calls = loop_record.unresolved_tool_calls.to_a
|
|
155
|
+
if pending_tool_calls.any?
|
|
156
|
+
token = SecureRandom.uuid
|
|
157
|
+
# Tool phase: running with a token but NO LLM lease (lease NULL marks
|
|
158
|
+
# "not an in-flight LLM turn"; the reaper's case 3 guard leaves a
|
|
159
|
+
# tool-phase loop with unresolved calls untouched — the per-tool lease
|
|
160
|
+
# reclaim owns that path).
|
|
161
|
+
loop_record.update!(status: :running, error_message: nil, execution_token: token, lease_expires_at: nil)
|
|
162
|
+
# Revoke any per-tool lease left by the paused/failed generation. The
|
|
163
|
+
# fresh generation owns these calls now; without this a stale (but not
|
|
164
|
+
# yet time-expired) lease from the previous worker would make the
|
|
165
|
+
# re-enqueued job's claim CAS no-op, stranding the resumed tool. Only
|
|
166
|
+
# UNEXECUTED calls carry a live-ish lease worth clearing; executed ones
|
|
167
|
+
# already NULL it at their checkpoint. (Finding 9: resume uses a fresh
|
|
168
|
+
# generation + the stable key; the old worker's late write is already
|
|
169
|
+
# fenced out by the rotated execution_token.)
|
|
170
|
+
SolidLoop::ToolCall
|
|
171
|
+
.where(id: pending_tool_calls.map(&:id), executed_at: nil)
|
|
172
|
+
.update_all(lease_token: nil, leased_until: nil, updated_at: Time.current)
|
|
173
|
+
# Mirror the emission policy: sequential agents re-enter one call at a
|
|
174
|
+
# time (ResponseCreation chains the rest); parallel agents re-enter
|
|
175
|
+
# every unresolved call so a multi-call turn cannot stall on resume.
|
|
176
|
+
calls = sequential_tool_calls? ? pending_tool_calls.first(1) : pending_tool_calls
|
|
177
|
+
calls.each { |tool_call| SolidLoop.enqueue!(ToolExecutionJob, tool_call.id, token) }
|
|
178
|
+
else
|
|
179
|
+
# Cleanup 4 — the no-unresolved-tools branch re-queues an LLM turn; the
|
|
180
|
+
# non-`running` target (queued) must NULL lease_expires_at so the DB
|
|
181
|
+
# CHECK holds and the reaper doesn't treat it as an in-flight LLM turn.
|
|
182
|
+
loop_record.update!(status: :queued, error_message: nil, execution_token: nil, lease_expires_at: nil)
|
|
183
|
+
SolidLoop.enqueue!(LlmCompletionJob, loop_record.id)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
true
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
class Loop < ApplicationRecord
|
|
3
|
+
belongs_to :subject, polymorphic: true, optional: true
|
|
4
|
+
has_many :messages, class_name: "SolidLoop::Message", foreign_key: :loop_id, dependent: :destroy, inverse_of: :loop
|
|
5
|
+
has_many :tool_calls, through: :messages, class_name: "SolidLoop::ToolCall"
|
|
6
|
+
has_many :events, class_name: "SolidLoop::Event", foreign_key: :loop_id, dependent: :destroy, inverse_of: :loop
|
|
7
|
+
has_many :mcp_sessions, class_name: "SolidLoop::McpSession", foreign_key: :loop_id, dependent: :destroy, inverse_of: :loop
|
|
8
|
+
has_many :mcp_tools, through: :mcp_sessions
|
|
9
|
+
|
|
10
|
+
def agent
|
|
11
|
+
return nil unless agent_class_name.present?
|
|
12
|
+
agent_class_name.constantize.new(self)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
enum :status, {
|
|
16
|
+
init: "init",
|
|
17
|
+
queued: "queued",
|
|
18
|
+
running: "running",
|
|
19
|
+
paused: "paused",
|
|
20
|
+
completed: "completed",
|
|
21
|
+
failed: "failed"
|
|
22
|
+
}, default: :init
|
|
23
|
+
|
|
24
|
+
ACTIVE_STATUSES = %w[init queued running].freeze
|
|
25
|
+
STOPPABLE_STATUSES = (ACTIVE_STATUSES + [ "paused" ]).freeze
|
|
26
|
+
|
|
27
|
+
def admin_frozen?
|
|
28
|
+
frozen_at.present?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# CAS on status (and optionally the generation token) under the row lock.
|
|
32
|
+
# An optional block runs INSIDE the lock, after the checks pass and before
|
|
33
|
+
# the status write: use it for generation-owned side writes (message
|
|
34
|
+
# states, counters) so a losing CAS performs zero mutations.
|
|
35
|
+
def transition_status(from:, to:, expected_execution_token: :any, **attributes)
|
|
36
|
+
transitioned = false
|
|
37
|
+
|
|
38
|
+
with_lock do
|
|
39
|
+
next unless Array(from).map(&:to_s).include?(status)
|
|
40
|
+
next if expected_execution_token != :any && execution_token != expected_execution_token
|
|
41
|
+
|
|
42
|
+
yield if block_given?
|
|
43
|
+
update!(**attributes, status: to)
|
|
44
|
+
transitioned = true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
transitioned
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Fence for generation-owned writes that keep the loop `running`: takes the
|
|
51
|
+
# row lock, re-validates that the caller's generation still owns the loop,
|
|
52
|
+
# and only then yields. Returns true when the block ran; on a stale token
|
|
53
|
+
# (pause/stop/resume has moved the generation on) nothing is written.
|
|
54
|
+
def with_generation(expected_execution_token)
|
|
55
|
+
owned = false
|
|
56
|
+
|
|
57
|
+
with_lock do
|
|
58
|
+
next unless running? && execution_token == expected_execution_token
|
|
59
|
+
|
|
60
|
+
yield
|
|
61
|
+
owned = true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
owned
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Unresolved tool calls of the CURRENT conversation tail only. Recovery
|
|
68
|
+
# must never resurrect an obsolete tool turn, so selection starts from the
|
|
69
|
+
# newest visible user/assistant message and proceeds only when that tail is
|
|
70
|
+
# an assistant turn carrying tool calls.
|
|
71
|
+
#
|
|
72
|
+
# TODO(backlog): resolution matches tool response messages by the
|
|
73
|
+
# `tool_call_id` string; a hard FK (+ unique index) from the tool response
|
|
74
|
+
# Message to its ToolCall row would make this join-able and typo-proof.
|
|
75
|
+
def unresolved_tool_calls
|
|
76
|
+
tail = messages
|
|
77
|
+
.where(is_hidden: false, role: %i[user assistant])
|
|
78
|
+
.order(id: :desc)
|
|
79
|
+
.first
|
|
80
|
+
return SolidLoop::ToolCall.none unless tail&.assistant?
|
|
81
|
+
|
|
82
|
+
response_ids = messages
|
|
83
|
+
.where(role: :tool)
|
|
84
|
+
.where("id > ?", tail.id)
|
|
85
|
+
.pluck(:tool_call_id)
|
|
86
|
+
|
|
87
|
+
tail.tool_calls.ordered.where.not(tool_call_id: response_ids)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Steering — enqueue a user message onto a live loop, delivered to the model
|
|
94
|
+
# at the NEXT turn boundary.
|
|
95
|
+
#
|
|
96
|
+
# A steering message is STAGED: `role: user, steering: true, is_hidden: true,
|
|
97
|
+
# status: pending`. Being hidden keeps it out of the in-flight turn's assembled
|
|
98
|
+
# conversation (MessageBuilding filters `is_hidden: false`); being `steering`
|
|
99
|
+
# + `pending` marks it as still-undelivered so the host app can show it as
|
|
100
|
+
# "pending" and so edit/cancel are allowed. `MessageBuilding#build_messages`
|
|
101
|
+
# materializes staged messages at the start of each turn (un-hide + mark
|
|
102
|
+
# `status: success`), which appends them AFTER the last resolved exchange and
|
|
103
|
+
# makes them immutable history. See `drain_pending_steering!`.
|
|
104
|
+
#
|
|
105
|
+
# Positioning: the live conversation orders by `COALESCE(conversation_order,
|
|
106
|
+
# id)`. An `id` alone is NOT enough — a steering message's `id` is frozen at
|
|
107
|
+
# ENQUEUE time, so a message enqueued mid-turn (after the assistant's
|
|
108
|
+
# `tool_calls` row is written but before its `tool` result rows are) sorts
|
|
109
|
+
# BETWEEN the assistant and its tool results, breaking the assistant/tool
|
|
110
|
+
# pairing invariant. Instead, delivery (see `drain_pending_steering!`) allocates
|
|
111
|
+
# a fresh, monotonic `conversation_order` value AT DELIVERY TIME, which sorts
|
|
112
|
+
# after the whole already-resolved batch. Ordinary messages leave
|
|
113
|
+
# `conversation_order` NULL and fall back to `id` unchanged.
|
|
114
|
+
STEERABLE_STATUSES = %w[init queued running paused].freeze
|
|
115
|
+
|
|
116
|
+
# Enqueue a free-text user message onto this loop. Returns the created
|
|
117
|
+
# (staged) Message, or raises if the loop is terminal.
|
|
118
|
+
def enqueue_user_message(content)
|
|
119
|
+
with_lock do
|
|
120
|
+
unless STEERABLE_STATUSES.include?(status)
|
|
121
|
+
raise SteeringError, "cannot steer a #{status} loop"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
messages.create!(
|
|
125
|
+
role: "user",
|
|
126
|
+
content: content,
|
|
127
|
+
steering: true,
|
|
128
|
+
is_hidden: true,
|
|
129
|
+
status: "pending"
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Still-pending (staged, not-yet-delivered) steering messages, in enqueue order.
|
|
135
|
+
def pending_user_messages
|
|
136
|
+
messages.where(steering: true, status: "pending").order(:id)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Edit a still-pending steering message's content. Rejected (raises) once the
|
|
140
|
+
# message has been delivered (materialized) or was cancelled. The row lock +
|
|
141
|
+
# status re-check fences against a concurrent materialize: whoever takes the
|
|
142
|
+
# lock first wins; a message mid-/post-materialize reads `status != pending`
|
|
143
|
+
# and the edit is refused.
|
|
144
|
+
def edit_pending_message(message_id, content)
|
|
145
|
+
message = messages.find(message_id)
|
|
146
|
+
|
|
147
|
+
message.with_lock do
|
|
148
|
+
unless message.steering? && message.pending?
|
|
149
|
+
raise SteeringError, "message #{message_id} is no longer pending; cannot edit"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
message.update!(content: content)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
message
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Cancel (delete) a still-pending steering message so it is never delivered.
|
|
159
|
+
# Rejected (raises) once delivered. Same fence as edit. Returns true.
|
|
160
|
+
def cancel_pending_message(message_id)
|
|
161
|
+
message = messages.find(message_id)
|
|
162
|
+
|
|
163
|
+
message.with_lock do
|
|
164
|
+
unless message.steering? && message.pending?
|
|
165
|
+
raise SteeringError, "message #{message_id} is no longer pending; cannot cancel"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
message.destroy!
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
true
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Materialize the loop's staged steering messages into the live conversation.
|
|
175
|
+
# Called at the START of each turn's `build_messages` — a resolved boundary
|
|
176
|
+
# (the previous turn's tool results are all written; no half-open batch is
|
|
177
|
+
# created between here and assembly).
|
|
178
|
+
#
|
|
179
|
+
# Exactly-once / crash-safety: each message is flipped `pending -> success` and
|
|
180
|
+
# un-hidden inside its own row lock, guarded by a `pending?` re-check. A retried
|
|
181
|
+
# turn-build (or a second concurrent build) re-reads the row as `success` and
|
|
182
|
+
# skips it — never re-materializing. A concurrent edit/cancel either won the
|
|
183
|
+
# lock first (message stays pending, gets materialized next) or lost it (sees
|
|
184
|
+
# `success` and is refused): no half-applied state.
|
|
185
|
+
def drain_pending_steering!
|
|
186
|
+
# One committed, row-locked transaction. Lock the pending
|
|
187
|
+
# steering rows FOR UPDATE in ID order and materialize them, then COMMIT —
|
|
188
|
+
# all before the caller assembles the payload, so assembly reads a settled
|
|
189
|
+
# conversation. Locking-and-reloading in one query closes the pluck-then-
|
|
190
|
+
# find gap: a row cancelled/deleted between selection and lock simply is
|
|
191
|
+
# not returned by the locking SELECT (tolerated). A concurrent edit/cancel
|
|
192
|
+
# either won its own row lock first (row stays pending → materialized next
|
|
193
|
+
# turn) or blocks here and then reads `success` and is refused.
|
|
194
|
+
transaction do
|
|
195
|
+
pending = pending_user_messages.lock("FOR UPDATE").to_a
|
|
196
|
+
pending.each do |message|
|
|
197
|
+
# Re-check under the held lock (defensive; the WHERE already filters).
|
|
198
|
+
next unless message.steering? && message.pending?
|
|
199
|
+
|
|
200
|
+
# Allocate the ordering value HERE, at delivery time — not the frozen
|
|
201
|
+
# enqueue-time id — so the message sorts after every already-resolved
|
|
202
|
+
# message written between enqueue and now (e.g. the tool results of a
|
|
203
|
+
# batch the assistant opened mid-turn).
|
|
204
|
+
message.update!(is_hidden: false, status: "success", conversation_order: next_conversation_order)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
after_update_commit :broadcast_status_change, if: :saved_change_to_status?
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
# A fresh, monotonic ordering value drawn from the table's EXISTING primary-key
|
|
214
|
+
# id sequence (no custom sequence — schema.rb / db:schema:load must stay clean).
|
|
215
|
+
# nextval is atomic, monotonic and gap-tolerant; occasionally consuming an id
|
|
216
|
+
# number is harmless.
|
|
217
|
+
def next_conversation_order
|
|
218
|
+
self.class.connection.select_value("SELECT nextval(pg_get_serial_sequence('solid_loop_messages', 'id'))")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def broadcast_status_change
|
|
222
|
+
ActiveSupport::Notifications.instrument("solid_loop.loop_status_changed", loop: self)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
# A session issued by Mcp::Server to an external MCP client — the inbound
|
|
3
|
+
# mirror of McpSession (which is shaped around the loop and does not apply
|
|
4
|
+
# here). One row per client connection; inbound Events hang off it.
|
|
5
|
+
# See docs/decisions/mcp-server.md.
|
|
6
|
+
class McpInboundSession < ApplicationRecord
|
|
7
|
+
self.table_name = "solid_loop_mcp_inbound_sessions"
|
|
8
|
+
|
|
9
|
+
has_many :events, class_name: "SolidLoop::Event", as: :eventable, dependent: :destroy
|
|
10
|
+
|
|
11
|
+
validates :server_name, presence: true
|
|
12
|
+
validates :session_id, presence: true, uniqueness: true
|
|
13
|
+
|
|
14
|
+
scope :active, -> { where(terminated_at: nil) }
|
|
15
|
+
|
|
16
|
+
# Sessions are bound to the authenticated principal's stable key AND the
|
|
17
|
+
# mount identity; in-session lookup scopes to both so a different valid
|
|
18
|
+
# principal cannot ride another caller's session_id. `principal` stays the
|
|
19
|
+
# human-readable label; `principal_key` is the identity. See Mcp::Server.
|
|
20
|
+
|
|
21
|
+
def terminated?
|
|
22
|
+
terminated_at.present?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
class McpSession < ApplicationRecord
|
|
3
|
+
self.table_name = "solid_loop_mcp_sessions"
|
|
4
|
+
|
|
5
|
+
belongs_to :loop, class_name: "SolidLoop::Loop", inverse_of: :mcp_sessions
|
|
6
|
+
has_many :mcp_tools, class_name: "SolidLoop::McpTool", foreign_key: :mcp_session_id, dependent: :destroy, inverse_of: :mcp_session
|
|
7
|
+
has_many :tool_calls, class_name: "SolidLoop::ToolCall", foreign_key: :mcp_session_id, dependent: :nullify, inverse_of: :mcp_session
|
|
8
|
+
|
|
9
|
+
validates :mcp_name, presence: true, uniqueness: { scope: :loop_id }
|
|
10
|
+
validates :session_id, presence: true
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
class McpTool < ApplicationRecord
|
|
3
|
+
self.table_name = "solid_loop_mcp_tools"
|
|
4
|
+
|
|
5
|
+
belongs_to :mcp_session, class_name: "SolidLoop::McpSession", inverse_of: :mcp_tools
|
|
6
|
+
|
|
7
|
+
scope :ordered, -> { order(:mcp_session_id, :id) }
|
|
8
|
+
|
|
9
|
+
validates :name, presence: true, uniqueness: { scope: :mcp_session_id }
|
|
10
|
+
|
|
11
|
+
def to_tool_spec
|
|
12
|
+
{
|
|
13
|
+
type: "function",
|
|
14
|
+
function: {
|
|
15
|
+
name: name,
|
|
16
|
+
description: description,
|
|
17
|
+
parameters: input_schema
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(agent:, arguments:, tool_call: nil)
|
|
23
|
+
SolidLoop::McpToolExecutionService.new(self, agent, tool_call: tool_call).call(arguments)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
class Message < ApplicationRecord
|
|
3
|
+
belongs_to :loop, inverse_of: :messages
|
|
4
|
+
has_many :tool_calls, class_name: "SolidLoop::ToolCall", dependent: :destroy, inverse_of: :message
|
|
5
|
+
has_many :events, as: :eventable, class_name: "SolidLoop::Event", dependent: :destroy, inverse_of: :eventable
|
|
6
|
+
enum :status, {
|
|
7
|
+
pending: "pending",
|
|
8
|
+
processing: "processing",
|
|
9
|
+
success: "success",
|
|
10
|
+
failed: "failed"
|
|
11
|
+
}, default: :pending
|
|
12
|
+
|
|
13
|
+
enum :role, {
|
|
14
|
+
system: "system",
|
|
15
|
+
user: "user",
|
|
16
|
+
assistant: "assistant",
|
|
17
|
+
tool: "tool"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
def tps
|
|
21
|
+
return 0.0 unless tokens_completion.to_i > 0
|
|
22
|
+
|
|
23
|
+
# Generation time starts from first token until the end
|
|
24
|
+
# total_duration is duration_generation
|
|
25
|
+
actual_gen_time = duration_generation.to_f - ttft.to_f
|
|
26
|
+
|
|
27
|
+
return 0.0 if actual_gen_time <= 0
|
|
28
|
+
|
|
29
|
+
(tokens_completion.to_f / actual_gen_time).round(2)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def cumulative_duration
|
|
33
|
+
# Sum of generation durations and tool durations for all messages in the loop up to (and including) this one
|
|
34
|
+
previous_messages = loop.messages.where("created_at <= ?", created_at)
|
|
35
|
+
|
|
36
|
+
gen_dur = previous_messages.sum(:duration_generation)
|
|
37
|
+
|
|
38
|
+
# Sum tool durations from tool calls associated with these messages
|
|
39
|
+
tool_dur = SolidLoop::ToolCall.where(message_id: previous_messages.map(&:id)).sum(:duration)
|
|
40
|
+
|
|
41
|
+
(gen_dur + tool_dur).round(3)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
after_create_commit :broadcast_creation
|
|
45
|
+
|
|
46
|
+
def trigger_content_append(new_content, new_reasoning = nil, tool_calls_raw = [], ttft: 0.0, duration_thinking: 0.0, duration_generation: 0.0, tokens_completion: 0)
|
|
47
|
+
update_columns(
|
|
48
|
+
content: new_content,
|
|
49
|
+
reasoning_content: new_reasoning,
|
|
50
|
+
tool_calls_raw: tool_calls_raw,
|
|
51
|
+
ttft: ttft,
|
|
52
|
+
duration_thinking: duration_thinking,
|
|
53
|
+
duration_generation: duration_generation,
|
|
54
|
+
tokens_completion: tokens_completion
|
|
55
|
+
)
|
|
56
|
+
broadcast_update
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def broadcast_update
|
|
60
|
+
ActiveSupport::Notifications.instrument("solid_loop.message_updated", message: self)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def broadcast_creation
|
|
66
|
+
ActiveSupport::Notifications.instrument("solid_loop.message_created", message: self)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
class ToolCall < ApplicationRecord
|
|
3
|
+
belongs_to :message, inverse_of: :tool_calls
|
|
4
|
+
belongs_to :mcp_session, class_name: "SolidLoop::McpSession", optional: true, inverse_of: :tool_calls
|
|
5
|
+
|
|
6
|
+
scope :ordered, -> { order(:id) }
|
|
7
|
+
|
|
8
|
+
# Stable per-invocation idempotency key. `executed_at` prevents SolidLoop
|
|
9
|
+
# from re-invoking a checkpointed tool, but a crash between a remote side
|
|
10
|
+
# effect and the checkpoint commit can still double-invoke: handlers of
|
|
11
|
+
# irreversible tools should dedupe on this key (delivered via
|
|
12
|
+
# `CallContext#idempotency_key` in-process and MCP `_meta` on the wire).
|
|
13
|
+
def idempotency_key
|
|
14
|
+
"solid_loop:tool_call:#{id}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The per-tool_call lease (docs/decisions/durable_attempt_lease.md).
|
|
18
|
+
# A live lease means THIS worker's `lease_token` is stamped and the lease has
|
|
19
|
+
# not expired. The canonical completion write re-checks this under the row
|
|
20
|
+
# lock (alongside the loop's execution_token) so a stale worker whose lease
|
|
21
|
+
# was revoked by pause/reclaim writes nothing canonical.
|
|
22
|
+
#
|
|
23
|
+
# `now` is passed explicitly so a single fenced transaction evaluates expiry
|
|
24
|
+
# against one consistent clock.
|
|
25
|
+
def lease_held_by?(token, now: Time.current)
|
|
26
|
+
token.present? &&
|
|
27
|
+
lease_token == token &&
|
|
28
|
+
leased_until.present? &&
|
|
29
|
+
leased_until > now
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def response_message
|
|
33
|
+
message.loop.messages
|
|
34
|
+
.where(role: :tool, tool_call_id: tool_call_id)
|
|
35
|
+
.where("id > ?", message_id)
|
|
36
|
+
.order(:id)
|
|
37
|
+
.first
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
after_commit { message.broadcast_update }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module Admin
|
|
3
|
+
class BaseQuery
|
|
4
|
+
attr_reader :params, :scope
|
|
5
|
+
|
|
6
|
+
def initialize(params, scope:)
|
|
7
|
+
@params = params
|
|
8
|
+
@scope = scope
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
scope
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def present_param(key)
|
|
18
|
+
value = params[key]
|
|
19
|
+
value.is_a?(String) ? value.strip.presence : value.presence
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module Admin
|
|
3
|
+
class EventsQuery < BaseQuery
|
|
4
|
+
def initialize(params, scope: SolidLoop::Event.order(created_at: :desc))
|
|
5
|
+
super
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call
|
|
9
|
+
filtered = scope
|
|
10
|
+
filtered = filtered.where(loop_id: loop_id) if loop_id
|
|
11
|
+
filtered = filtered.where(name: name) if name
|
|
12
|
+
filtered = filtered.where(eventable_type: eventable_type) if eventable_type
|
|
13
|
+
filtered
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def loop_id
|
|
19
|
+
present_param(:loop_id)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def name
|
|
23
|
+
present_param(:name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def eventable_type
|
|
27
|
+
present_param(:eventable_type)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|