Class: Silas::Turn
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Silas::Turn
- 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
- #active? ⇒ Boolean
-
#answer_data ⇒ Object
The structured payload when the agent declares a final_answer schema in agent.yml (nil otherwise) — the parsed Hash, not a string to re-parse.
-
#answer_text ⇒ Object
The agent's answer for this turn: the last completed step's text blocks.
-
#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).
-
#cancel!(reason: "canceled") ⇒ Object
Cancel a turn.
- #canceled? ⇒ Boolean
- #completed? ⇒ Boolean
- #expire_pending_approvals!(reason) ⇒ Object
- #failed? ⇒ Boolean
-
#finish!(new_status, reason: nil) ⇒ Object
Duration on this event spans the WHOLE turn, parked time included — it answers "how long did the customer wait", not "how much compute".
- #parked? ⇒ Boolean
-
#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).
Instance Method Details
#active? ⇒ Boolean
21 |
# File 'app/models/silas/turn.rb', line 21 def active? = ACTIVE_STATUSES.include?(status) |
#answer_data ⇒ Object
The structured payload when the agent declares a final_answer schema in agent.yml (nil otherwise) — the parsed Hash, not a string to re-parse.
95 96 97 98 99 100 |
# File 'app/models/silas/turn.rb', line 95 def answer_data step = steps.where(status: "completed").order(:index).last return nil unless step Array(step.response_blocks).reverse.find { |b| b["type"] == "structured" }&.dig("data") end |
#answer_text ⇒ Object
The agent's answer for this turn: the last completed step's text blocks.
86 87 88 89 90 91 |
# File 'app/models/silas/turn.rb', line 86 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).
64 65 66 |
# File 'app/models/silas/turn.rb', line 64 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).
42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'app/models/silas/turn.rb', line 42 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
34 |
# File 'app/models/silas/turn.rb', line 34 def canceled? = status == "canceled" |
#completed? ⇒ Boolean
19 |
# File 'app/models/silas/turn.rb', line 19 def completed? = status == "completed" |
#expire_pending_approvals!(reason) ⇒ Object
55 56 57 58 59 60 |
# File 'app/models/silas/turn.rb', line 55 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 |
#failed? ⇒ Boolean
20 |
# File 'app/models/silas/turn.rb', line 20 def failed? = status == "failed" |
#finish!(new_status, reason: nil) ⇒ Object
Duration on this event spans the WHOLE turn, parked time included — it answers "how long did the customer wait", not "how much compute".
26 27 28 29 30 31 32 |
# File 'app/models/silas/turn.rb', line 26 def finish!(new_status, reason: nil) Silas.instrument(:turn, status: new_status.to_s, reason: reason, turn_id: id, session_id: session_id, agent: session.agent_name, steps: steps.count) do update!(status: new_status.to_s, failure_reason: reason, finished_at: Time.current) end end |
#parked? ⇒ Boolean
22 |
# File 'app/models/silas/turn.rb', line 22 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)
73 74 75 76 77 78 79 80 81 82 83 |
# File 'app/models/silas/turn.rb', line 73 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 |