Module: Silas::Compactor
- Defined in:
- lib/silas/compactor.rb
Overview
Keeps long sessions under the model's context window by summarising prior turns into a Compaction row — the alternative today is the provider rejecting the prompt and the turn failing.
The design constraint comes from MessageBuilder: replayed executions must produce byte-identical message arrays, so a summary can never be computed at build time. Compaction is therefore an EFFECT, made exactly-once the same way tool effects are:
- ensure! runs at the top of each step, INSIDE the isolated continuation
step. A crash anywhere re-runs the whole step, and the unique index on
(session_id, up_to_turn_index) makes the claim idempotent.
- The summary model call happens once; the row it completes is what
MessageBuilder reads from then on. A crash mid-summary leaves a
pending row; the re-run summarises again and completes it — no step
ever executed against the lost draft.
- The boundary is fixed for the whole turn (all PRIOR turns, 0..index-1),
so a re-executed step finds the same row a crashed attempt created:
same rows in, same messages out.
What it deliberately does not do (v1): compact within the current turn — a single turn's growth is bounded by max_steps, while a session's turn count is unbounded, and mid-turn boundaries would cut between a tool_use and its result. If the current turn alone outgrows the window, that is max_steps' problem, not compaction's.
Constant Summary collapse
- SUMMARY_SYSTEM =
<<~PROMPT.freeze You are compacting the earlier part of a long conversation so it can continue within the model's context window. Write a dense summary that preserves everything a future turn might rely on: facts and figures, decisions made, tool calls and their results (ids, amounts, names), commitments given to the user, and anything explicitly left open. Write it as plain prose. Do not add commentary about the summarisation itself. PROMPT
- TOOL_RESULT_CHARS =
Per-block caps keep the summarisation call itself well under the window.
2_000- TEXT_CHARS =
4_000
Class Method Summary collapse
- .claim!(turn, boundary, measured) ⇒ Object
- .context_window_for(model) ⇒ Object
-
.ensure!(turn) ⇒ Object
Called before each step's model call.
-
.measured_context(turn) ⇒ Object
The provider's own measure of the prompt: the last completed step's input_tokens IS the context size of the previous model call.
- .step_lines(step) ⇒ Object
- .summarise!(turn, row) ⇒ Object
-
.threshold_for(turn) ⇒ Object
config.compact_at: a Float in (0, 1] is a fraction of the model's registry context window (unknown window => off); an Integer is an absolute token threshold (works with any adapter — custom engines and the chaos harness have no registry entry).
-
.transcript_for(turn, row) ⇒ Object
The span rendered as a plain-text transcript — one user message into the summary call.
Class Method Details
.claim!(turn, boundary, measured) ⇒ Object
98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/silas/compactor.rb', line 98 def claim!(turn, boundary, measured) Compaction.create!( session_id: turn.session_id, up_to_turn: Turn.find_by!(session_id: turn.session_id, index: boundary), up_to_turn_index: boundary, status: "pending", tokens_before: measured ) rescue ActiveRecord::RecordNotUnique # A racing execution claimed it. Pending -> finish their summary work; # completed -> nothing to do (nil tells the caller to just read). row = Compaction.find_by!(session_id: turn.session_id, up_to_turn_index: boundary) row.completed? ? nil : row end |
.context_window_for(model) ⇒ Object
92 93 94 95 96 |
# File 'lib/silas/compactor.rb', line 92 def context_window_for(model) ::RubyLLM.models.find(model).context_window rescue StandardError nil end |
.ensure!(turn) ⇒ Object
Called before each step's model call. Returns the applicable completed Compaction (or nil), creating it first when the context has outgrown the threshold.
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/silas/compactor.rb', line 47 def ensure!(turn) threshold = threshold_for(turn) return Compaction.latest_for(turn) if threshold.nil? # feature off: existing rows still apply return nil if turn.index.zero? # no prior turns to compact boundary = turn.index - 1 if (existing = Compaction.find_by(session_id: turn.session_id, up_to_turn_index: boundary)) # Pending = a crash mid-summary. The trigger decision was already made # and no step ever ran against the lost draft, so finish the claimed # work rather than re-litigating the threshold. summarise!(turn, existing) unless existing.completed? return Compaction.latest_for(turn) end measured = measured_context(turn) return Compaction.latest_for(turn) if measured.nil? || measured < threshold row = claim!(turn, boundary, measured) summarise!(turn, row) if row # nil: a racer claimed AND completed it Compaction.latest_for(turn) end |
.measured_context(turn) ⇒ Object
The provider's own measure of the prompt: the last completed step's input_tokens IS the context size of the previous model call. Persisted, so a replayed trigger decision reads the same number.
72 73 74 75 76 77 |
# File 'lib/silas/compactor.rb', line 72 def measured_context(turn) Step.joins(:turn) .where(silas_turns: { session_id: turn.session_id }) .where.not(input_tokens: nil) .order(id: :desc).limit(1).pick(:input_tokens) end |
.step_lines(step) ⇒ Object
164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/silas/compactor.rb', line 164 def step_lines(step) lines = Array(step.response_blocks).filter_map do |b| case b["type"] when "text" then "Assistant: #{b['text'].to_s.truncate(TEXT_CHARS)}" when "structured" then "Assistant (structured answer): #{JSON.generate(b['data'])}" end end ToolInvocation.where(step_id: step.id).order(:id).each do |inv| lines << "Assistant called #{inv.tool_name}(#{JSON.generate(inv.arguments || {})}) " \ "-> #{JSON.generate(inv.result || inv.status).truncate(TOOL_RESULT_CHARS)}" end lines end |
.summarise!(turn, row) ⇒ Object
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/silas/compactor.rb', line 113 def summarise!(turn, row) model = StepRunner.turn_model(turn) Silas.instrument(:compact, session_id: turn.session_id, turn_id: turn.id, up_to_turn_index: row.up_to_turn_index, tokens_before: row.tokens_before, model: model) do |payload| context = { turn: turn, index: nil, compaction: true, system: SUMMARY_SYSTEM, messages: [ { role: "user", content: transcript_for(turn, row) } ], tools: [], model: model, final_answer: nil, limits: {} } engine = Silas.resolved_adapter result = if (hook = Silas.config.around_model_call) hook.call(context) { engine.execute_step(context) } else engine.execute_step(context) end summary = result.blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] }.join row.update!(status: "completed", summary: summary, model: model, input_tokens: result.usage&.dig(:input_tokens), output_tokens: result.usage&.dig(:output_tokens)) payload[:input_tokens] = row.input_tokens payload[:output_tokens] = row.output_tokens end end |
.threshold_for(turn) ⇒ Object
config.compact_at: a Float in (0, 1] is a fraction of the model's registry context window (unknown window => off); an Integer is an absolute token threshold (works with any adapter — custom engines and the chaos harness have no registry entry). nil/false disables.
83 84 85 86 87 88 89 90 |
# File 'lib/silas/compactor.rb', line 83 def threshold_for(turn) setting = Silas.config.compact_at return nil unless setting return setting if setting.is_a?(Integer) window = context_window_for(StepRunner.turn_model(turn)) window && (window * setting.to_f).to_i end |
.transcript_for(turn, row) ⇒ Object
The span rendered as a plain-text transcript — one user message into the summary call. Recursive by construction: a previous summary opens the transcript, so each compaction folds the last one in.
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/silas/compactor.rb', line 145 def transcript_for(turn, row) parts = [] previous = Compaction.completed .where(session_id: turn.session_id) .where(up_to_turn_index: ...row.up_to_turn_index) .order(:up_to_turn_index).last parts << "Earlier conversation, already summarised:\n#{previous.summary}" if previous floor = previous ? previous.up_to_turn_index : -1 Turn.where(session_id: turn.session_id, index: (floor + 1)..row.up_to_turn_index) .order(:index).each do |prior| parts << "User: #{prior.input}" Step.where(turn_id: prior.id).order(:index).select(&:completed?).each do |step| parts.concat(step_lines(step)) end end parts.join("\n\n") end |