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
-
.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).
-
.call(turn, upto_index:) ⇒ Object
Canonical provider-agnostic shape; adapters map it to their wire format.
-
.compaction_preamble(compaction) ⇒ Object
Pure function of the row — no clock, no counts of anything mutable.
- .step_messages(turn, upto_index:) ⇒ Object
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). A structured (final_answer) block replays as its JSON text — deterministic (same rows -> same string), and providers need message content, not our block type.
75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/silas/message_builder.rb', line 75 def assistant_blocks(step, settled) text = Array(step.response_blocks).filter_map do |b| case b["type"] when "text" then b when "structured" then { "type" => "text", "text" => JSON.generate(b["data"]) } end end 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 30 31 32 33 34 35 36 37 38 39 40 41 |
# File 'lib/silas/message_builder.rb', line 16 def call(turn, upto_index:) = [] # A completed Compaction row replaces turns 0..up_to_turn_index with its # persisted summary. Reading the ROW keeps this deterministic: the # summary was generated once (Compactor's CAS claim) and never # recomputed, so same rows -> same array, compacted or not. compaction = Compaction.latest_for(turn) floor = -1 if compaction << { role: "user", content: compaction_preamble(compaction) } floor = compaction.up_to_turn_index end Turn.where(session_id: turn.session_id).order(:index).each do |prior| next if prior.index <= floor break if prior.index >= turn.index << { role: "user", content: prior.input } .concat((prior, upto_index: nil)) end << { role: "user", content: turn.input } .concat((turn, upto_index: upto_index)) end |
.compaction_preamble(compaction) ⇒ Object
Pure function of the row — no clock, no counts of anything mutable.
44 45 46 47 48 |
# File 'lib/silas/message_builder.rb', line 44 def compaction_preamble(compaction) "[The earlier part of this conversation was summarised to stay within " \ "the context window. Summary of everything before this point:]\n\n" \ "#{compaction.summary}" end |
.step_messages(turn, upto_index:) ⇒ Object
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/silas/message_builder.rb', line 50 def (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 |