Class: Silas::ToolInvocation
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Silas::ToolInvocation
- Includes:
- Inbox::Broadcastable
- Defined in:
- app/models/silas/tool_invocation.rb
Constant Summary collapse
- STATUSES =
%w[pending started completed failed in_doubt].freeze
- EFFECT_MODES =
%w[transactional at_most_once idempotent].freeze
- APPROVAL_STATES =
[ nil, "required", "approved", "answered", "declined", "expired" ].freeze
Class Method Summary collapse
-
.expire_stale!(now: Time.current) ⇒ Object
Sweep for the rescuer: expire parked invocations past their TTL and fail their turns (parked-forever ghosts are a bug, not a feature).
Instance Method Summary collapse
-
#answer!(text:, by: nil) ⇒ Object
Answer a parked question.
-
#approve!(by: nil) ⇒ Object
Approve a parked invocation and resume the turn with a FRESH job (the parked job exited normally; its continuation is consumed).
- #awaiting_approval? ⇒ Boolean
- #completed? ⇒ Boolean
-
#decline!(reason:, by: nil) ⇒ Object
Decline: for an approval gate, eve's shape — the tool is not executed and the model sees reason as the result, then the loop continues.
- #in_doubt? ⇒ Boolean
- #notify_channel_approval ⇒ Object
-
#question? ⇒ Boolean
A parked ask_question — same park, different verdict: it is ANSWERED (free text becomes the tool result), never approved into execution.
- #should_notify_approval? ⇒ Boolean
Class Method Details
.expire_stale!(now: Time.current) ⇒ Object
Sweep for the rescuer: expire parked invocations past their TTL and fail their turns (parked-forever ghosts are a bug, not a feature).
86 87 88 89 90 91 92 93 94 95 |
# File 'app/models/silas/tool_invocation.rb', line 86 def self.expire_stale!(now: Time.current) where(approval_state: "required").where(approval_expires_at: ..now).find_each do |inv| result = inv.question? ? { "answer" => nil, "note" => "question expired unanswered" } : { "denied" => "approval expired" } inv.update!(approval_state: "expired", status: "failed", result: result) Silas.instrument(:approval, action: "expired", tool: inv.tool_name, invocation_id: inv.id, turn_id: inv.turn_id) inv.turn.finish!(:failed, reason: "approval_expired") end end |
Instance Method Details
#answer!(text:, by: nil) ⇒ Object
Answer a parked question. The text IS the tool result — the model resumes with => text, persisted like any other settled invocation, so replay determinism costs nothing. (decline! also works on a question: a refusal to answer, delivered as => reason.)
57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'app/models/silas/tool_invocation.rb', line 57 def answer!(text:, by: nil) raise Error, "invocation #{id} (#{tool_name}) is not a question — answer! settles ask_question only" unless question? raise Error, "an answer cannot be blank — decline! is the way to refuse a question" if text.blank? assert_parked! assert_turn_resumable! update!(status: "completed", approval_state: "answered", approved_by: by, result: { "answer" => text }) Silas.instrument(:approval, action: "answered", tool: tool_name, by: by, invocation_id: id, turn_id: turn_id) resume_turn! end |
#approve!(by: nil) ⇒ Object
Approve a parked invocation and resume the turn with a FRESH job (the parked job exited normally; its continuation is consumed). For an in-doubt invocation, approval means "it did not run — re-execute".
40 41 42 43 44 45 46 47 48 49 50 51 |
# File 'app/models/silas/tool_invocation.rb', line 40 def approve!(by: nil) if question? raise Error, "invocation #{id} is a question — settle it with answer!, not approve! " \ "(approving would try to EXECUTE ask_question, which has no execution)" end assert_parked! assert_turn_resumable! update!(status: "pending", approval_state: "approved", approved_by: by) Silas.instrument(:approval, action: "approved", tool: tool_name, by: by, invocation_id: id, turn_id: turn_id) resume_turn! end |
#awaiting_approval? ⇒ Boolean
19 |
# File 'app/models/silas/tool_invocation.rb', line 19 def awaiting_approval? = approval_state == "required" |
#completed? ⇒ Boolean
17 |
# File 'app/models/silas/tool_invocation.rb', line 17 def completed? = status == "completed" |
#decline!(reason:, by: nil) ⇒ Object
Decline: for an approval gate, eve's shape — the tool is not executed and the model sees reason as the result, then the loop continues. For an in-doubt invocation, decline means "assume it ran / abandon" — the operator-supplied reason becomes the recorded outcome.
74 75 76 77 78 79 80 81 82 |
# File 'app/models/silas/tool_invocation.rb', line 74 def decline!(reason:, by: nil) assert_parked! assert_turn_resumable! update!(status: "failed", approval_state: "declined", approved_by: by, decline_reason: reason, result: { "denied" => reason }) Silas.instrument(:approval, action: "declined", tool: tool_name, by: by, invocation_id: id, turn_id: turn_id) resume_turn! end |
#in_doubt? ⇒ Boolean
18 |
# File 'app/models/silas/tool_invocation.rb', line 18 def in_doubt? = status == "in_doubt" |
#notify_channel_approval ⇒ Object
33 34 35 |
# File 'app/models/silas/tool_invocation.rb', line 33 def notify_channel_approval ChannelDeliveryJob.perform_later("approval", id) end |
#question? ⇒ Boolean
A parked ask_question — same park, different verdict: it is ANSWERED (free text becomes the tool result), never approved into execution.
23 |
# File 'app/models/silas/tool_invocation.rb', line 23 def question? = tool_name == "ask_question" |
#should_notify_approval? ⇒ Boolean
29 30 31 |
# File 'app/models/silas/tool_invocation.rb', line 29 def should_notify_approval? saved_change_to_approval_state? && approval_state == "required" && turn.session.channel.present? end |