Class: Insika::ToolEnvelope

Inherits:
SimpleDelegator
  • Object
show all
Defined in:
lib/insika/tool_envelope.rb

Overview

Wraps each allowed tool: per-call timeout

  • recording of a non-idempotent side-effect BEFORE the result returns to the model. Delegates everything else (name/description/params) to the real tool.

The tool loop belongs to RubyLLM; this is a decorator over the instances — the Executor never drives roundtrips.

Instance Method Summary collapse

Constructor Details

#initialize(tool, state:, checkpoint_store:, tool_registry:, timeout:, skip_side_effects: [], trace_recorder: nil) ⇒ ToolEnvelope

Returns a new instance of ToolEnvelope.



23
24
25
26
27
28
29
30
31
32
# File 'lib/insika/tool_envelope.rb', line 23

def initialize(tool, state:, checkpoint_store:, tool_registry:, timeout:,
               skip_side_effects: [], trace_recorder: nil)
  super(tool)
  @state = state
  @checkpoint_store = checkpoint_store
  @tool_registry = tool_registry
  @timeout = timeout
  @skip_side_effects = Array(skip_side_effects) # ids already completed in the interrupted turn
  @trace_recorder = trace_recorder # duck-type: #record(session_id:, entry:). nil = no trace.
end

Instance Method Details

#call(args) ⇒ Object

Entry point that RubyLLM invokes (Tool#call in the pinned version). A timeout overflow returns to the MODEL as a serialized error — it does not bring down the turn.



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/insika/tool_envelope.rb', line 37

def call(args)
  # A non-idempotent tool call ALREADY COMPLETED in the interrupted
  # turn -> respond with a marker, NEVER re-execute. The marker returns to
  # the model, keeping the tool-use protocol intact.
  call_id = correlation_id
  return { "skipped" => "already_executed" } if call_id && @skip_side_effects.include?(call_id)

  # Approval gate: a tool marked `approval` suspends the turn in
  # :waiting until the operator resolves it. Delegates to the coordinator (the
  # Executor), which creates/queries the PendingAction and blocks via the
  # mailbox. Rejection returns to the MODEL as an error (the turn continues),
  # it does not bring down the turn. CancelledError/TimeoutError from the wait
  # propagate (they are not ToolTimeout).
  if approval_required?
    decision = @state.approval_coordinator.request_approval(
      task: @state.task, turn: @state.turn, tool: real_name, args: args, actor: @state.actor
    )
    return { error: "rejected by operator" } unless decision.to_s == "approved"
  end

  started = monotonic
  result = with_gate { Async::Task.current.with_timeout(@timeout, ToolTimeout) { __getobj__.call(args) } }
  record_side_effect!(call_id) if side_effect?
  trace(call_id, args, result, started)
  result
rescue ToolTimeout
  err = { error: "TimeoutError: tool exceeded #{@timeout}s" }
  trace(call_id, args, err, started)
  err
end