Class: SolidLoop::Base

Inherits:
Object
  • Object
show all
Defined in:
app/models/solid_loop/base.rb

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(loop_record) ⇒ Base

Returns a new instance of Base.



9
10
11
12
# File 'app/models/solid_loop/base.rb', line 9

def initialize(loop_record)
  @loop_record = loop_record
  @subject = loop_record.subject
end

Class Attribute Details

.llm_providerObject

Returns the value of attribute llm_provider.



4
5
6
# File 'app/models/solid_loop/base.rb', line 4

def llm_provider
  @llm_provider
end

Instance Attribute Details

#loop_recordObject (readonly)

Returns the value of attribute loop_record.



7
8
9
# File 'app/models/solid_loop/base.rb', line 7

def loop_record
  @loop_record
end

#subjectObject (readonly)

Returns the value of attribute subject.



7
8
9
# File 'app/models/solid_loop/base.rb', line 7

def subject
  @subject
end

Class Method Details

.create!(subject:, message:, system_prompt: nil, **state) ⇒ Object



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'app/models/solid_loop/base.rb', line 14

def self.create!(subject:, message:, system_prompt: nil, **state)
  loop_record = SolidLoop::Loop.create!(
    subject: subject,
    agent_class_name: name,
    state: state
  )

  agent_instance = new(loop_record)

  system_prompt_content = system_prompt || begin
    agent_instance.system_prompt
  rescue NotImplementedError
    nil
  end

  if system_prompt_content.present?
    loop_record.messages.create!(role: "system", content: system_prompt_content)
  end

  loop_record.messages.create!(role: "user", content: message)

  loop_record
end

.start!(subject:, message:, system_prompt: nil, **state) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
# File 'app/models/solid_loop/base.rb', line 38

def self.start!(subject:, message:, system_prompt: nil, **state)
  loop_record = create!(subject: subject, message: message, system_prompt: system_prompt, **state)

  # State advancement and dispatch commit atomically (same-database
  # transactional job backend): no queued loop without its job.
  loop_record.transaction do
    loop_record.update!(status: "queued")
    SolidLoop.enqueue!(LlmCompletionJob, loop_record.id)
  end

  loop_record
end

Instance Method Details

#configure_llm_middlewares(builder) ⇒ Object



55
56
# File 'app/models/solid_loop/base.rb', line 55

def configure_llm_middlewares(builder)
end

#configure_tool_middlewares(builder) ⇒ Object



58
59
# File 'app/models/solid_loop/base.rb', line 58

def configure_tool_middlewares(builder)
end

#llm_adapter_nameObject



80
81
82
# File 'app/models/solid_loop/base.rb', line 80

def llm_adapter_name
  :native
end

#llm_dialect_nameObject



84
85
86
# File 'app/models/solid_loop/base.rb', line 84

def llm_dialect_name
  :open_ai
end

#max_costObject



109
110
111
# File 'app/models/solid_loop/base.rb', line 109

def max_cost
  10.0
end

#max_durationObject



113
114
115
# File 'app/models/solid_loop/base.rb', line 113

def max_duration
  2.hours
end

#max_stepsObject



101
102
103
# File 'app/models/solid_loop/base.rb', line 101

def max_steps
  100
end

#max_tokensObject



97
98
99
# File 'app/models/solid_loop/base.rb', line 97

def max_tokens
  16 * 1024 # Default
end

#max_total_tokensObject



105
106
107
# File 'app/models/solid_loop/base.rb', line 105

def max_total_tokens
  1_000_000
end

#mcp_principalObject



93
94
95
# File 'app/models/solid_loop/base.rb', line 93

def mcp_principal
  subject
end

#mcpsObject



51
52
53
# File 'app/models/solid_loop/base.rb', line 51

def mcps
  []
end

#on_mcp_session_initialized(mcp_session) ⇒ Object



125
126
127
# File 'app/models/solid_loop/base.rb', line 125

def on_mcp_session_initialized(mcp_session)
  # Hook called when an MCP session is established for the agent.
end

#on_message_created(message) ⇒ Object



121
122
123
# File 'app/models/solid_loop/base.rb', line 121

def on_message_created(message)
  # Hook called whenever a new message is created in the loop.
end

#pause!Object



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'app/models/solid_loop/base.rb', line 129

def pause!
  # Cleanup 6 — pausing synchronously fails+hides any orphaned `processing`
  # assistant shell (born-hidden shell protocol) inside the same transition
  # txn, so no paused loop retains a hidden `processing` shell. The block runs
  # under the loop lock.
  loop_record.transition_status(
    from: SolidLoop::Loop::ACTIVE_STATUSES,
    to: :paused,
    execution_token: nil,
    lease_expires_at: nil
  ) do
    loop_record.messages.where(role: "assistant", status: "processing").find_each do |shell|
      shell.update!(status: "failed", is_hidden: true)
    end
  end
end

#reasoning_strategiesObject



88
89
90
91
# File 'app/models/solid_loop/base.rb', line 88

def reasoning_strategies
  # Default to the most robust method for returning thoughts: XML wrapping
  [ :xml ]
end

#refresh_mcp_tools?Boolean

Returns:

  • (Boolean)


61
62
63
# File 'app/models/solid_loop/base.rb', line 61

def refresh_mcp_tools?
  false
end

#resume!Object



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'app/models/solid_loop/base.rb', line 146

def resume!
  # State advancement and dispatch commit atomically inside the lock's
  # transaction (same-database transactional job backend): a crash leaves
  # neither a running loop without jobs nor jobs for a rolled-back resume.
  loop_record.with_lock do
    raise StandardError, "Unfreeze in admin UI before resuming this loop" if loop_record.admin_frozen?
    return false unless loop_record.init? || loop_record.paused? || loop_record.failed? || loop_record.completed?

    pending_tool_calls = loop_record.unresolved_tool_calls.to_a
    if pending_tool_calls.any?
      token = SecureRandom.uuid
      # Tool phase: running with a token but NO LLM lease (lease NULL marks
      # "not an in-flight LLM turn"; the reaper's case 3 guard leaves a
      # tool-phase loop with unresolved calls untouched — the per-tool lease
      # reclaim owns that path).
      loop_record.update!(status: :running, error_message: nil, execution_token: token, lease_expires_at: nil)
      # Revoke any per-tool lease left by the paused/failed generation. The
      # fresh generation owns these calls now; without this a stale (but not
      # yet time-expired) lease from the previous worker would make the
      # re-enqueued job's claim CAS no-op, stranding the resumed tool. Only
      # UNEXECUTED calls carry a live-ish lease worth clearing; executed ones
      # already NULL it at their checkpoint. (Finding 9: resume uses a fresh
      # generation + the stable key; the old worker's late write is already
      # fenced out by the rotated execution_token.)
      SolidLoop::ToolCall
        .where(id: pending_tool_calls.map(&:id), executed_at: nil)
        .update_all(lease_token: nil, leased_until: nil, updated_at: Time.current)
      # Mirror the emission policy: sequential agents re-enter one call at a
      # time (ResponseCreation chains the rest); parallel agents re-enter
      # every unresolved call so a multi-call turn cannot stall on resume.
      calls = sequential_tool_calls? ? pending_tool_calls.first(1) : pending_tool_calls
      calls.each { |tool_call| SolidLoop.enqueue!(ToolExecutionJob, tool_call.id, token) }
    else
      # Cleanup 4 — the no-unresolved-tools branch re-queues an LLM turn; the
      # non-`running` target (queued) must NULL lease_expires_at so the DB
      # CHECK holds and the reaper doesn't treat it as an in-flight LLM turn.
      loop_record.update!(status: :queued, error_message: nil, execution_token: nil, lease_expires_at: nil)
      SolidLoop.enqueue!(LlmCompletionJob, loop_record.id)
    end
  end

  true
end

#sequential_tool_calls?Boolean

When false (default), all of a turn's tool calls are enqueued at once and run in parallel across workers. When true, they run strictly one-at-a-time in model order (the next call is enqueued only after the previous result lands), and the execution-boundary guard rejects out-of-order jobs.

HITL / per-tool approval gating is cleanest with true: a single in-flight tool at a time means an approval decision has one unambiguous step to gate.

Returns:

  • (Boolean)


76
77
78
# File 'app/models/solid_loop/base.rb', line 76

def sequential_tool_calls?
  false
end

#streaming?Boolean

Returns:

  • (Boolean)


65
66
67
# File 'app/models/solid_loop/base.rb', line 65

def streaming?
  true # Default backwards compatibility
end

#system_promptObject

Raises:

  • (NotImplementedError)


117
118
119
# File 'app/models/solid_loop/base.rb', line 117

def system_prompt
  raise NotImplementedError, "You must implement #system_prompt in #{self.class.name}"
end