Class: Silas::Turn

Inherits:
ApplicationRecord show all
Includes:
Inbox::Broadcastable
Defined in:
app/models/silas/turn.rb

Constant Summary collapse

STATUSES =
%w[queued running waiting in_doubt completed failed canceled].freeze
ACTIVE_STATUSES =
%w[queued running waiting in_doubt].freeze

Instance Method Summary collapse

Instance Method Details

#active?Boolean

Returns:

  • (Boolean)


20
# File 'app/models/silas/turn.rb', line 20

def active?    = ACTIVE_STATUSES.include?(status)

#answer_textObject

The agent's answer for this turn: the last completed step's text blocks.



79
80
81
82
83
84
# File 'app/models/silas/turn.rb', line 79

def answer_text
  step = steps.where(status: "completed").order(:index).last
  return "" unless step

  Array(step.response_blocks).select { |b| b["type"] == "text" }.map { |b| b["text"] }.join
end

#budget_parked?Boolean

Parked by a budget cap (failure_reason doubles as the park reason while the turn is waiting; it is cleared on resume).

Returns:

  • (Boolean)


57
58
59
# File 'app/models/silas/turn.rb', line 57

def budget_parked?
  waiting? && Budget::REASONS.include?(failure_reason)
end

#cancel!(reason: "canceled") ⇒ Object

Cancel a turn. A PARKED or QUEUED turn (no live execution) settles to canceled immediately, expiring its pending approvals so a later approve! can't zombie-resume it. A RUNNING turn is flagged; the loop honors the flag at the next step boundary — the in-flight model call completes and its step commits (aborting mid-step would forfeit paid work and create an in-doubt tool window for nothing).

Raises:



35
36
37
38
39
40
41
42
43
44
45
46
# File 'app/models/silas/turn.rb', line 35

def cancel!(reason: "canceled")
  raise Error, "turn #{id} is already terminal (#{status})" unless active?

  if running?
    update!(cancel_requested_at: Time.current)
    :cancel_requested
  else
    expire_pending_approvals!(reason)
    finish!(:canceled, reason: reason)
    :canceled
  end
end

#canceled?Boolean

Returns:

  • (Boolean)


27
# File 'app/models/silas/turn.rb', line 27

def canceled? = status == "canceled"

#completed?Boolean

Returns:

  • (Boolean)


19
# File 'app/models/silas/turn.rb', line 19

def completed? = status == "completed"

#expire_pending_approvals!(reason) ⇒ Object



48
49
50
51
52
53
# File 'app/models/silas/turn.rb', line 48

def expire_pending_approvals!(reason)
  tool_invocations.where(approval_state: "required").find_each do |inv|
    inv.update!(approval_state: "expired", status: "failed",
                result: { "denied" => reason })
  end
end

#finish!(new_status, reason: nil) ⇒ Object



23
24
25
# File 'app/models/silas/turn.rb', line 23

def finish!(new_status, reason: nil)
  update!(status: new_status.to_s, failure_reason: reason, finished_at: Time.current)
end

#parked?Boolean

Returns:

  • (Boolean)


21
# File 'app/models/silas/turn.rb', line 21

def parked?    = status == "waiting" || status == "in_doubt"

#raise_budget!(max_cost: nil, max_input_tokens: nil, timeout: nil) ⇒ Object

Human top-up for a budget-parked turn: record the raised limit(s) and resume with a fresh job — completed steps replay from rows, no model re-calls, no re-effects (the same resume path approvals use). turn.raise_budget!(max_cost: 1.50) # dollars turn.raise_budget!(max_input_tokens: 200_000, timeout: 3600)

Raises:



66
67
68
69
70
71
72
73
74
75
76
# File 'app/models/silas/turn.rb', line 66

def raise_budget!(max_cost: nil, max_input_tokens: nil, timeout: nil)
  raise Error, "turn #{id} is not budget-parked (#{status}/#{failure_reason})" unless budget_parked?

  raises = { "max_cost" => max_cost, "max_input_tokens" => max_input_tokens,
             "timeout" => timeout }.compact
  raise ArgumentError, "pass at least one limit to raise" if raises.empty?

  update!(budget_overrides: (budget_overrides || {}).merge(raises),
          failure_reason: nil, status: "queued")
  AgentLoopJob.perform_later(id)
end