Module: Silas::MessageBuilder

Defined in:
lib/silas/message_builder.rb

Overview

Rebuilds the provider conversation deterministically from persisted rows — the rows ARE the transcript (no separate events table in v0.1). Replayed executions must produce byte-identical message arrays, so nothing here may read mutable state or the clock.

Class Method Summary collapse

Class Method Details

.assistant_blocks(step, settled) ⇒ Object

Text comes from the model's own blocks; tool_use blocks are rebuilt from the settled invocations so the assistant message and the tool results that follow are always a matched set (same ids, same count).



54
55
56
57
58
59
60
61
# File 'lib/silas/message_builder.rb', line 54

def assistant_blocks(step, settled)
  text = Array(step.response_blocks).select { |b| b["type"] == "text" }
  tools = settled.map do |inv|
    { "type" => "tool_call", "id" => inv.tool_call_id,
      "name" => inv.tool_name, "arguments" => inv.arguments || {} }
  end
  text + tools
end

.call(turn, upto_index:) ⇒ Object

Canonical provider-agnostic shape; adapters map it to their wire format. { role: "user", content: "..." } { role: "assistant", content: [ ...blocks ] } { role: "tool", tool_call_id:, content: ... } NOTE: always fresh queries, never cached associations — this runs inside a live loop where rows were created moments ago on the same objects, and a memoized empty turn.steps silently erases the model's own history.



16
17
18
19
20
21
22
23
24
25
26
27
28
29
# File 'lib/silas/message_builder.rb', line 16

def call(turn, upto_index:)
  messages = []

  Turn.where(session_id: turn.session_id).order(:index).each do |prior|
    break if prior.index >= turn.index

    messages << { role: "user", content: prior.input }
    messages.concat(step_messages(prior, upto_index: nil))
  end

  messages << { role: "user", content: turn.input }
  messages.concat(step_messages(turn, upto_index: upto_index))
  messages
end

.step_messages(turn, upto_index:) ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/silas/message_builder.rb', line 31

def step_messages(turn, upto_index:)
  Step.where(turn_id: turn.id).order(:index).each_with_object([]) do |step, acc|
    next unless step.completed?
    next if upto_index && step.index >= upto_index

    # The settled invocations are the ledger's source of truth for what the
    # model actually invoked. Reconstruct the assistant's tool_use blocks
    # from them (not from step.response_blocks, which can drift when the
    # model emits parallel tool calls) so every tool_result replayed below
    # has a matching tool_use by construction — the provider requires it.
    settled = ToolInvocation.where(step_id: step.id).order(:id)
                            .select { |inv| inv.status == "completed" || inv.status == "failed" }

    acc << { role: "assistant", content: assistant_blocks(step, settled) }
    settled.each do |inv|
      acc << { role: "tool", tool_call_id: inv.tool_call_id, content: inv.result }
    end
  end
end