Class: Silas::ToolInvocation

Inherits:
ApplicationRecord show all
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

Instance Method Summary collapse

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.)

Raises:



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

Returns:

  • (Boolean)


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

def awaiting_approval? = approval_state == "required"

#completed?Boolean

Returns:

  • (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

Returns:

  • (Boolean)


18
# File 'app/models/silas/tool_invocation.rb', line 18

def in_doubt?  = status == "in_doubt"

#notify_channel_approvalObject



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.

Returns:

  • (Boolean)


23
# File 'app/models/silas/tool_invocation.rb', line 23

def question? = tool_name == "ask_question"

#should_notify_approval?Boolean

Returns:

  • (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