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,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,7 @@
1
+ module SolidLoop
2
+ class Event < ApplicationRecord
3
+ # Optional: inbound MCP events (Mcp::Server) have no loop.
4
+ belongs_to :loop, class_name: "SolidLoop::Loop", inverse_of: :events, optional: true
5
+ belongs_to :eventable, polymorphic: true
6
+ end
7
+ 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