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
-
.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.
-
.call(turn, index) ⇒ Object
Returns :continue, :terminal, or :parked.
- .effect_mode_for(tool_name) ⇒ Object
- .execute_model_call(turn, index) ⇒ Object
- .park_turn!(turn, step) ⇒ Object
- .turn_model(_turn) ⇒ Object
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 |