Module: Silas::StepRunner

Defined in:
lib/silas/step_runner.rb

Overview

The idempotent body of one continuation step: at most one model call per (turn, index), replay feeds from rows, tool execution goes through the Ledger. Every path must be safe to run twice (crash replay) and safe to run in a fresh job (post-approval resume).

Class Method Summary collapse

Class Method Details

.assert_definitions_unchanged!(turn) ⇒ Object

A deploy that changes tools/skills mid-turn must fail loudly, never resume into a different agent than the one that started the turn.



72
73
74
75
76
77
78
79
80
81
82
# File 'lib/silas/step_runner.rb', line 72

def assert_definitions_unchanged!(turn)
  return if turn.definitions_digest.blank? || Silas.config.definitions_digest.nil?

  live = Silas.config.definitions_digest.call.to_s
  return if live == turn.definitions_digest

  turn.finish!(:failed, reason: "definitions_changed")
  raise NondeterminismError,
        "Tool/skill definitions changed mid-turn (digest #{turn.definitions_digest[0, 12]}… → " \
        "#{live[0, 12]}…). The turn was failed rather than resumed against a different agent."
end

.call(turn, index) ⇒ Object

Returns :continue, :terminal, or :parked.



10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/silas/step_runner.rb', line 10

def call(turn, index)
  step = Step.find_or_create_by!(turn: turn, index: index)

  unless step.completed?
    result = execute_model_call(turn, index)

    # One transaction: the step's response, its terminal verdict, and the
    # pending ledger rows commit together — or none of them do.
    ApplicationRecord.transaction do
      step.update!(
        status: "completed",
        response_blocks: result.blocks,
        stop_reason: result.stop_reason,
        terminal: result.terminal?,
        model: turn_model(turn),
        input_tokens: result.usage&.dig(:input_tokens),
        output_tokens: result.usage&.dig(:output_tokens)
      )
      result.tool_calls.each do |tc|
        ToolInvocation.create!(
          step: step, turn: turn,
          tool_call_id: tc.id, tool_name: tc.name, arguments: tc.arguments,
          effect_mode: effect_mode_for(tc.name)
        )
      end
    rescue ActiveRecord::RecordNotUnique
      # A racing execution committed this step first; fall through to
      # settle from the persisted rows.
    end
    step.reload
  end

  case Ledger.settle!(step, resolver: Silas.tool_resolver)
  when :parked
    park_turn!(turn, step)
    :parked
  else
    step.terminal? ? :terminal : :continue
  end
end

.effect_mode_for(tool_name) ⇒ Object



89
90
91
92
# File 'lib/silas/step_runner.rb', line 89

def effect_mode_for(tool_name)
  tool = Silas.tool_resolver.call(tool_name)
  tool.respond_to?(:effect_mode) ? tool.effect_mode.to_s : "at_most_once"
end

.execute_model_call(turn, index) ⇒ Object



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/silas/step_runner.rb', line 51

def execute_model_call(turn, index)
  assert_definitions_unchanged!(turn)
  engine = Silas.resolved_engine
  context = {
    turn: turn,
    index: index,
    system: turn.instructions_snapshot,
    messages: MessageBuilder.call(turn, upto_index: index),
    tools: Silas.tool_definitions,
    model: turn_model(turn),
    limits: { max_steps: Silas.agent.max_steps }
  }
  if (hook = Silas.config.around_model_call)
    hook.call(context) { engine.execute_step(context) }
  else
    engine.execute_step(context)
  end
end

.park_turn!(turn, step) ⇒ Object



84
85
86
87
# File 'lib/silas/step_runner.rb', line 84

def park_turn!(turn, step)
  new_status = step.tool_invocations.reload.any?(&:in_doubt?) ? "in_doubt" : "waiting"
  turn.update!(status: new_status)
end

.turn_model(_turn) ⇒ Object



94
95
96
# File 'lib/silas/step_runner.rb', line 94

def turn_model(_turn)
  Silas.agent.model
end