turnkit 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 271ce272a71a97aa2991a580f36205e4cef8e19466e2e480b0ac6f0f0225d51f
4
- data.tar.gz: b9a0503f499d3eb850e7eece6f508b6fbc206d6398263f6005520b7ef716493b
3
+ metadata.gz: 34429a11d156c9631705ec193c77c2ad166fb3dffc182a7b730cffd38b52f694
4
+ data.tar.gz: c497d2042388a33e80c037145e82a6adf1cc47286073441b7fb7f21fcd4a89b7
5
5
  SHA512:
6
- metadata.gz: f8772f25a95c44b2ba3d1a17a3e89d0ba142d862e798cee6daef9c54e04deaa3d8dee77deae48b5a77f7b6051b467a14c355aabf5115b1ce89832a27c87eb1b6
7
- data.tar.gz: 9b12cccaa55c8d791168eca90655e3b9db89409b69fe59f8b45d23bef71aeec296c538696af44e484da9884dfde4ace67bbfd81d4a6647783f1f7f299ef0e485
6
+ metadata.gz: 330444b7c8964271b8f11ec562f22c331cf6f00d470880082edc1efa263c33708e68b436ed29276c417ec173044993ecb105c05c788fa84405ac34f90f9521a2
7
+ data.tar.gz: 5bb9900c687ffa6c9eed0678c0d1a36bba08c79ceb0a4ab3767046e772394b4794c5f81f2b9a52142411873f8aea1bc19e148b101b00fc4fd1cb6fe89933f531
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.6 - 2026-06-07
4
+
5
+ - Add automatic context compaction for long conversations. TurnKit now stores append-only `context_summary` messages and projects compacted history into future model calls while keeping the full transcript durable.
6
+
3
7
  ## 0.2.5 - 2026-06-06
4
8
 
5
9
  - Add per-agent and per-turn provider thinking configuration.
data/README.md CHANGED
@@ -148,6 +148,93 @@ turn = conversation.run!
148
148
  puts turn.output_text
149
149
  ```
150
150
 
151
+ ### Context compaction
152
+
153
+ TurnKit automatically compacts long conversations. Older messages are summarized for future model calls, while the original transcript remains stored durably.
154
+
155
+ ```ruby
156
+ conversation = agent.conversation
157
+ conversation.ask("Work through this long task.")
158
+ ```
159
+
160
+ By default, compaction is enabled and uses the current turn model for the summary call. If a turn runs with `gpt-5`, compaction uses `gpt-5` unless you configure a separate summary model.
161
+
162
+ Disable compaction globally:
163
+
164
+ ```ruby
165
+ TurnKit.compaction = false
166
+ ```
167
+
168
+ Use a different model for summaries:
169
+
170
+ ```ruby
171
+ TurnKit.compaction = {
172
+ model: "gpt-4.1-mini"
173
+ }
174
+ ```
175
+
176
+ You can also configure the compaction threshold and estimated context limit:
177
+
178
+ ```ruby
179
+ TurnKit.compaction = {
180
+ model: "gpt-4.1-mini",
181
+ threshold: 0.75,
182
+ context_limit: 128_000
183
+ }
184
+ ```
185
+
186
+ Configure compaction for one agent:
187
+
188
+ ```ruby
189
+ agent = TurnKit::Agent.new(
190
+ name: "engineer",
191
+ model: "gpt-5",
192
+ compaction: {
193
+ model: "gpt-4.1-mini",
194
+ threshold: 0.75,
195
+ context_limit: 128_000
196
+ }
197
+ )
198
+ ```
199
+
200
+ In this example, normal turns use `gpt-5` and compaction summaries use `gpt-4.1-mini`.
201
+
202
+ Override the model for one manual compaction:
203
+
204
+ ```ruby
205
+ conversation.compact!(model: "gpt-4.1-mini")
206
+ conversation.compact!(focus: "billing migration", model: "gpt-4.1-mini")
207
+ ```
208
+
209
+ Disable compaction for a single turn:
210
+
211
+ ```ruby
212
+ conversation.ask("Continue", compact: false)
213
+ ```
214
+
215
+ Manually compact a conversation:
216
+
217
+ ```ruby
218
+ conversation.compact!
219
+ conversation.compact!(focus: "billing migration")
220
+ ```
221
+
222
+ Compaction is append-only: TurnKit stores a `context_summary` message with metadata describing the message range it replaces for model projection. The original messages are not deleted, so `conversation.messages` remains the full durable transcript. Future model calls see a compacted projection that includes a reference-only summary and the recent tail.
223
+
224
+ The model-visible projection uses a synthetic summary exchange followed by recent messages:
225
+
226
+ ```text
227
+ user: What did we do so far?
228
+ assistant: [CONTEXT COMPACTION — REFERENCE ONLY] ...
229
+ user: latest request
230
+ ```
231
+
232
+ For a local smoke test without calling a real provider, run:
233
+
234
+ ```sh
235
+ ruby script/manual_compaction.rb
236
+ ```
237
+
151
238
  ### Tools
152
239
 
153
240
  Create a tool:
@@ -539,6 +626,7 @@ TurnKit.cost_limit = nil
539
626
  TurnKit.cost_rates = {}
540
627
  TurnKit.cost_calculator = nil
541
628
  TurnKit.prompt_cache = :auto
629
+ TurnKit.compaction = true
542
630
  ```
543
631
 
544
632
  Override an agent:
@@ -567,6 +655,7 @@ agent = TurnKit::Agent.new(
567
655
  | `cost_rates` | Override prices by model. |
568
656
  | `cost_calculator` | Override cost calculation. |
569
657
  | `prompt_cache` | Use provider prompt caching. |
658
+ | `compaction` | Enable, disable, or configure automatic context compaction. |
570
659
 
571
660
  ## Contributing
572
661
 
data/lib/turnkit/agent.rb CHANGED
@@ -4,11 +4,11 @@ module TurnKit
4
4
  class Agent
5
5
  attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
6
6
  attr_reader :client, :store, :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions
7
- attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking
7
+ attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction
8
8
 
9
9
  def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], available_skills: [], sub_agents: [],
10
10
  system_prompt: nil, prompt_sections: nil, prompt_mode: nil, client: nil, store: nil,
11
- max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil, thinking: nil)
11
+ max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil, thinking: nil, compaction: nil)
12
12
  @name = name.to_s
13
13
  @description = description.to_s
14
14
  @model = model
@@ -28,6 +28,7 @@ module TurnKit
28
28
  @max_depth = max_depth
29
29
  @max_tool_executions = max_tool_executions
30
30
  @thinking = self.class.normalize_thinking(thinking)
31
+ @compaction = compaction
31
32
  raise ArgumentError, "name is required" if @name.empty?
32
33
  end
33
34
 
@@ -0,0 +1,406 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ module Compaction
5
+ DEFAULTS = {
6
+ "enabled" => true,
7
+ "threshold" => 0.75,
8
+ "context_limit" => 128_000,
9
+ "reserved_tokens" => 20_000,
10
+ "head_messages" => 0,
11
+ "tail_messages" => 12,
12
+ "tail_tokens" => 8_000,
13
+ "summary_ratio" => 0.20,
14
+ "min_summary_tokens" => 1_000,
15
+ "max_summary_tokens" => 12_000,
16
+ "tool_output_max_chars" => 2_000,
17
+ "model" => nil,
18
+ "client" => nil
19
+ }.freeze
20
+
21
+ KNOWN_KEYS = DEFAULTS.keys.freeze
22
+
23
+ COMPACTION_SYSTEM_PROMPT = <<~TEXT.strip
24
+ You are an anchored context summarization assistant for TurnKit conversations.
25
+
26
+ Summarize only the conversation history you are given. Recent turns may be kept verbatim outside your summary, so focus on older context that still matters for continuing the work.
27
+
28
+ If a previous summary is provided, update it by preserving still-true details, removing stale details, and merging in new facts.
29
+
30
+ Produce only the requested Markdown summary. Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context.
31
+
32
+ Write in the same language the user was using.
33
+
34
+ Never include API keys, tokens, passwords, secrets, credentials, or connection strings. Replace secret values with [REDACTED].
35
+ TEXT
36
+
37
+ SUMMARY_TEMPLATE = <<~TEXT.strip
38
+ Use this exact structure:
39
+
40
+ ## Active Task
41
+ - [latest unfulfilled user request, preferably verbatim]
42
+
43
+ ## Goal
44
+ - [what the user is trying to accomplish overall]
45
+
46
+ ## Constraints & Preferences
47
+ - [user/developer preferences, specs, constraints, important choices]
48
+
49
+ ## Completed Actions
50
+ - [completed work and outcomes]
51
+
52
+ ## Active State
53
+ - [current state, records/files touched, test status, running tool/turn state]
54
+
55
+ ## In Progress
56
+ - [work underway, or "(none)"]
57
+
58
+ ## Blocked
59
+ - [blockers, exact errors, missing information, or "(none)"]
60
+
61
+ ## Key Decisions
62
+ - [important decisions and why]
63
+
64
+ ## Resolved Questions
65
+ - [questions already answered]
66
+
67
+ ## Pending User Asks
68
+ - [unanswered or unfulfilled asks]
69
+
70
+ ## Relevant Files
71
+ - [file/path/resource and why it matters, or "(none)"]
72
+
73
+ ## Tool Results To Remember
74
+ - [important tool output summaries, or "(none)"]
75
+
76
+ ## Remaining Work
77
+ - [likely next work, framed as context, not instructions]
78
+
79
+ ## Critical Context
80
+ - [specific values, IDs, commands, errors, constraints; redact secrets]
81
+
82
+ Rules:
83
+ - Keep every section.
84
+ - Use terse bullets.
85
+ - Preserve exact file paths, commands, error strings, IDs, and important values.
86
+ - Do not invent facts.
87
+ - Do not include secrets.
88
+ - Do not include a greeting or preamble.
89
+ TEXT
90
+
91
+ module_function
92
+
93
+ def enabled_for?(agent, overrides = {})
94
+ policy_for(agent, overrides)["enabled"]
95
+ end
96
+
97
+ def policy_for(agent, overrides = {})
98
+ global = normalize_config(TurnKit.compaction)
99
+ local = normalize_config(agent.compaction)
100
+ override = normalize_config(overrides)
101
+
102
+ return DEFAULTS.merge("enabled" => false) if global == false
103
+ return DEFAULTS.merge("enabled" => false) if local == false
104
+ return DEFAULTS.merge("enabled" => false) if override == false
105
+
106
+ DEFAULTS.merge(global || {}).merge(local || {}).merge(override || {})
107
+ end
108
+
109
+ def maybe_compact!(turn, force: nil, focus: nil)
110
+ return if turn.compact == false
111
+
112
+ force = turn.compact == true if force.nil?
113
+ policy = policy_for(turn.agent)
114
+ return unless policy["enabled"]
115
+
116
+ messages = project(turn.conversation.messages_for_turn(turn))
117
+ return unless force || over_threshold?(messages, policy)
118
+
119
+ compact!(turn.conversation, agent: turn.agent, turn: turn, focus: focus, auto: true, overrides: policy, force: true)
120
+ rescue StandardError => error
121
+ TurnKit.logger&.warn("TurnKit compaction failed: #{error.class}: #{error.message}")
122
+ nil
123
+ end
124
+
125
+ def compact!(conversation, agent:, turn: nil, focus: nil, auto: false, overrides: {}, force: true)
126
+ policy = policy_for(agent, overrides)
127
+ raise CompactionError, "compaction is disabled" unless policy["enabled"]
128
+
129
+ messages = turn ? conversation.messages_for_turn(turn) : conversation.messages
130
+ projected = project(messages)
131
+ selected = select_messages(projected, policy)
132
+ return nil if selected.nil? && auto
133
+ raise CompactionError, "not enough messages to compact" unless selected
134
+
135
+ selected_tokens = estimate_messages_tokens(selected.fetch("middle"))
136
+ return nil if auto && !force && !over_threshold?(projected, policy)
137
+
138
+ summary = generate_summary(
139
+ agent: agent,
140
+ policy: policy,
141
+ messages: selected.fetch("middle"),
142
+ previous_summary: selected["previous_summary"]&.text,
143
+ focus: focus,
144
+ target_tokens: summary_budget(selected_tokens, policy),
145
+ fallback_model: turn&.model || conversation.model || agent.effective_model,
146
+ conversation_id: conversation.id,
147
+ turn_id: turn&.id
148
+ )
149
+
150
+ append_summary(conversation, turn: turn, summary: summary, selected: selected, policy: policy, focus: focus, auto: auto, input_tokens: selected_tokens)
151
+ rescue CompactionError
152
+ raise
153
+ rescue StandardError => error
154
+ raise CompactionError, "#{error.class}: #{error.message}"
155
+ end
156
+
157
+ def project(messages)
158
+ rows = Array(messages).sort_by { |message| [ message.sequence.to_i, message.id ] }
159
+ summaries = active_summaries(rows)
160
+ ranges = summaries.filter_map { |summary| range_for(summary) }
161
+ summaries_by_id = summaries.to_h { |summary| [ summary.id, summary ] }
162
+ inserted = {}
163
+ projected = []
164
+
165
+ rows.each do |message|
166
+ summaries.each do |summary|
167
+ range = range_for(summary)
168
+ next unless range
169
+ next if inserted[summary.id]
170
+ next unless range.begin <= message.sequence.to_i
171
+
172
+ projected << summary
173
+ inserted[summary.id] = true
174
+ end
175
+
176
+ if message.context_summary?
177
+ projected << message if summaries_by_id[message.id] && !inserted[message.id] && !range_for(message)
178
+ inserted[message.id] = true if summaries_by_id[message.id]
179
+ next
180
+ end
181
+
182
+ next if ranges.any? { |range| range.cover?(message.sequence.to_i) }
183
+
184
+ projected << message
185
+ end
186
+
187
+ summaries.each do |summary|
188
+ next if inserted[summary.id]
189
+
190
+ projected << summary
191
+ inserted[summary.id] = true
192
+ end
193
+
194
+ projected
195
+ end
196
+
197
+ def estimate_messages_tokens(messages)
198
+ Array(messages).sum { |message| estimate_text_tokens(message.text) + 8 }
199
+ end
200
+
201
+ def estimate_text_tokens(text)
202
+ (text.to_s.length / 4.0).ceil
203
+ end
204
+
205
+ def summary_budget(input_tokens, policy)
206
+ budget = (input_tokens.to_i * policy["summary_ratio"].to_f).ceil
207
+ budget = [ budget, policy["min_summary_tokens"].to_i ].max
208
+ [ budget, policy["max_summary_tokens"].to_i ].min
209
+ end
210
+
211
+ def over_threshold?(messages, policy)
212
+ usable = [ policy["context_limit"].to_i - policy["reserved_tokens"].to_i, 1 ].max
213
+ estimate_messages_tokens(messages) >= (usable * policy["threshold"].to_f)
214
+ end
215
+
216
+ def select_messages(messages, policy)
217
+ rows = Array(messages)
218
+ return nil if rows.length <= policy["head_messages"].to_i + 1
219
+
220
+ previous_summary = rows.reverse.find(&:context_summary?)
221
+ candidates = rows.reject(&:context_summary?)
222
+ return nil if candidates.length <= policy["head_messages"].to_i + 1
223
+
224
+ head_count = policy["head_messages"].to_i
225
+ tail_start = tail_start_index(candidates, policy)
226
+ tail_start = [ tail_start, head_count ].max
227
+ tail_start = expand_tail_start_for_tool_pairs(candidates, tail_start)
228
+ middle = candidates[head_count...tail_start]
229
+ return nil if middle.nil? || middle.empty?
230
+
231
+ from_sequence = middle.first.sequence.to_i
232
+ through_sequence = middle.last.sequence.to_i
233
+ if previous_summary
234
+ from_sequence = [ from_sequence, previous_summary.sequence.to_i ].min
235
+ through_sequence = [ through_sequence, previous_summary.sequence.to_i ].max
236
+ end
237
+
238
+ {
239
+ "middle" => middle,
240
+ "previous_summary" => previous_summary,
241
+ "replaces_from_sequence" => from_sequence,
242
+ "replaces_through_sequence" => through_sequence,
243
+ "tail_start_sequence" => candidates[tail_start]&.sequence
244
+ }
245
+ end
246
+
247
+ def build_prompt(previous_summary:, focus:, target_tokens:)
248
+ parts = []
249
+ if previous_summary && !previous_summary.empty?
250
+ parts << <<~TEXT.strip
251
+ Update the anchored summary below using the conversation history above.
252
+
253
+ Preserve still-true details, remove stale details, and merge in new facts. Remove stale details that are no longer relevant or have been superseded.
254
+
255
+ <previous-summary>
256
+ #{previous_summary}
257
+ </previous-summary>
258
+ TEXT
259
+ else
260
+ parts << <<~TEXT.strip
261
+ Create a structured context checkpoint for the conversation history above.
262
+
263
+ This summary will replace older TurnKit messages in future model prompts while the original messages remain stored durably.
264
+ TEXT
265
+ end
266
+
267
+ if focus && !focus.to_s.strip.empty?
268
+ parts << <<~TEXT.strip
269
+ Focus topic: "#{focus}"
270
+
271
+ Preserve extra detail related to this focus topic. Summarize unrelated context more aggressively, but do not omit constraints or active blockers that affect the current task.
272
+ TEXT
273
+ end
274
+
275
+ parts << "Target length: approximately #{target_tokens} tokens."
276
+ parts << SUMMARY_TEMPLATE
277
+ parts.join("\n\n")
278
+ end
279
+
280
+ def normalize_config(value)
281
+ case value
282
+ when nil, true
283
+ nil
284
+ when false
285
+ false
286
+ when Hash
287
+ attrs = value.transform_keys(&:to_s)
288
+ unknown = attrs.keys - KNOWN_KEYS
289
+ raise ConfigError, "unknown compaction options: #{unknown.join(", ")}" if unknown.any?
290
+
291
+ attrs
292
+ else
293
+ raise ConfigError, "compaction must be true, false, nil, or a Hash"
294
+ end
295
+ end
296
+
297
+ def range_for(summary)
298
+ metadata = summary.compaction_metadata
299
+ from = metadata["replaces_from_sequence"]
300
+ through = metadata["replaces_through_sequence"]
301
+ return nil unless from && through
302
+
303
+ (from.to_i..through.to_i)
304
+ end
305
+
306
+ def active_summaries(messages)
307
+ summaries = Array(messages).select(&:context_summary?).sort_by { |summary| summary.sequence.to_i }
308
+ active = []
309
+
310
+ summaries.reverse_each do |summary|
311
+ next if active.any? { |newer| (range_for(newer)&.cover?(summary.sequence.to_i)) }
312
+
313
+ active << summary
314
+ end
315
+
316
+ active.reverse
317
+ end
318
+
319
+ def tail_start_index(messages, policy)
320
+ max_messages = policy["tail_messages"].to_i
321
+ max_tokens = policy["tail_tokens"].to_i
322
+ count = 0
323
+ tokens = 0
324
+ index = messages.length
325
+
326
+ (messages.length - 1).downto(0) do |i|
327
+ message_tokens = estimate_text_tokens(messages[i].text) + 8
328
+ break if count >= max_messages
329
+ break if count.positive? && tokens + message_tokens > max_tokens
330
+
331
+ count += 1
332
+ tokens += message_tokens
333
+ index = i
334
+ end
335
+
336
+ index
337
+ end
338
+
339
+ def expand_tail_start_for_tool_pairs(messages, tail_start)
340
+ index = tail_start
341
+ while index.positive? && messages[index]&.tool_result?
342
+ call_id = messages[index].metadata["tool_call_id"]
343
+ call_index = (index - 1).downto(0).find do |i|
344
+ messages[i].tool_call? && Array(messages[i].metadata["tool_calls"]).any? { |call| call["id"] == call_id || call[:id] == call_id }
345
+ end
346
+ break unless call_index
347
+
348
+ index = call_index
349
+ end
350
+ index
351
+ end
352
+
353
+ def generate_summary(agent:, policy:, messages:, previous_summary:, focus:, target_tokens:, fallback_model:, conversation_id:, turn_id:)
354
+ client = policy["client"] || agent.effective_client
355
+ model = policy["model"] || fallback_model
356
+ safe_messages = messages.map { |message| sanitize_message(message, policy) }
357
+ prompt = build_prompt(previous_summary: previous_summary, focus: focus, target_tokens: target_tokens)
358
+ result = client.chat(
359
+ model: model,
360
+ messages: MessageProjection.for(safe_messages) + [ { role: :user, content: prompt } ],
361
+ tools: [],
362
+ instructions: COMPACTION_SYSTEM_PROMPT,
363
+ metadata: { compaction: true, conversation_id: conversation_id, turn_id: turn_id }
364
+ )
365
+ text = result.text.to_s.strip
366
+ raise CompactionError, "compaction model returned an empty summary" if text.empty?
367
+
368
+ text
369
+ end
370
+
371
+ def sanitize_message(message, policy)
372
+ return message unless message.tool_result?
373
+
374
+ max = policy["tool_output_max_chars"].to_i
375
+ return message if max <= 0 || message.text.length <= max
376
+
377
+ attrs = message.to_h
378
+ text = "#{message.text[0, max]}\n\n[Tool result truncated for compaction]"
379
+ Message.new(attrs.merge("text" => text, "content" => [ { "type" => "text", "text" => text } ]))
380
+ end
381
+
382
+ def append_summary(conversation, turn:, summary:, selected:, policy:, focus:, auto:, input_tokens:)
383
+ model = policy["model"] || turn&.model || conversation.model || conversation.agent.effective_model
384
+ conversation.append_message(
385
+ role: "assistant",
386
+ kind: "context_summary",
387
+ text: summary,
388
+ turn_id: turn&.id,
389
+ metadata: {
390
+ "compaction" => {
391
+ "auto" => auto,
392
+ "focus" => focus,
393
+ "replaces_from_sequence" => selected.fetch("replaces_from_sequence"),
394
+ "replaces_through_sequence" => selected.fetch("replaces_through_sequence"),
395
+ "tail_start_sequence" => selected["tail_start_sequence"],
396
+ "summary_model" => model,
397
+ "input_tokens" => input_tokens,
398
+ "summary_tokens" => estimate_text_tokens(summary),
399
+ "created_for_turn_id" => turn&.id,
400
+ "created_at" => Clock.now.iso8601
401
+ }.compact
402
+ }
403
+ )
404
+ end
405
+ end
406
+ end
@@ -26,15 +26,16 @@ module TurnKit
26
26
  async ? turn : turn.run!
27
27
  end
28
28
 
29
- def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET)
30
- build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, depth: depth, agent: agent, thinking: thinking).run!
29
+ def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil)
30
+ build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, depth: depth, agent: agent, thinking: thinking, compact: compact).run!
31
31
  end
32
32
 
33
- def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET)
33
+ def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil)
34
34
  snapshot = latest_message_sequence
35
35
  effective_thinking = thinking.equal?(THINKING_UNSET) ? agent.effective_thinking : Agent.normalize_thinking(thinking)
36
36
  options = { "trigger_message_id" => trigger_message_id }.compact
37
37
  options["thinking"] = effective_thinking
38
+ options["compact"] = compact unless compact.nil?
38
39
  record = store.create_turn(
39
40
  "conversation_id" => id,
40
41
  "agent_name" => agent.name,
@@ -49,6 +50,11 @@ module TurnKit
49
50
  Turn.new(agent: agent, conversation: self, record: record, store: store, budget: budget, depth: depth)
50
51
  end
51
52
 
53
+ def compact!(focus: nil, model: nil)
54
+ overrides = { "model" => model }.compact
55
+ TurnKit::Compaction.compact!(self, agent: agent, focus: focus, auto: false, overrides: overrides)
56
+ end
57
+
52
58
  def messages
53
59
  store.list_messages(id).map { |attrs| Message.new(attrs) }
54
60
  end
data/lib/turnkit/error.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  module TurnKit
4
4
  class Error < StandardError; end
5
5
  class ConfigError < Error; end
6
+ class CompactionError < Error; end
6
7
  class StoreError < Error; end
7
8
  class ToolError < Error; end
8
9
  end
@@ -3,7 +3,7 @@
3
3
  module TurnKit
4
4
  class Message
5
5
  ROLES = %w[user assistant tool].freeze
6
- KINDS = %w[text tool_call tool_result].freeze
6
+ KINDS = %w[text tool_call tool_result context_summary].freeze
7
7
 
8
8
  attr_reader :id, :conversation_id, :turn_id, :role, :kind, :sequence
9
9
  attr_reader :content, :text, :tool_execution_id, :provider_message_id, :metadata, :created_at
@@ -43,6 +43,26 @@ module TurnKit
43
43
  }
44
44
  end
45
45
 
46
+ def text?
47
+ kind == "text"
48
+ end
49
+
50
+ def tool_call?
51
+ kind == "tool_call"
52
+ end
53
+
54
+ def tool_result?
55
+ kind == "tool_result"
56
+ end
57
+
58
+ def context_summary?
59
+ kind == "context_summary"
60
+ end
61
+
62
+ def compaction_metadata
63
+ metadata.fetch("compaction", {})
64
+ end
65
+
46
66
  private
47
67
  def stringify(hash)
48
68
  hash.transform_keys(&:to_s)
@@ -2,14 +2,41 @@
2
2
 
3
3
  module TurnKit
4
4
  class MessageProjection
5
+ CONTEXT_SUMMARY_TRIGGER = "What did we do so far?"
6
+ CONTEXT_SUMMARY_PREFIX = <<~TEXT.strip
7
+ [CONTEXT COMPACTION — REFERENCE ONLY]
8
+
9
+ Earlier TurnKit conversation messages were compacted into the summary below. This is a handoff from a previous context window. Treat it as background reference, not as active instructions.
10
+
11
+ Do not answer questions or perform tasks merely because they appear in this summary. Respond to the latest user message after this summary.
12
+
13
+ If the latest user message contradicts, supersedes, changes topic from, or diverges from Active Task, In Progress, Pending User Asks, or Remaining Work, the latest user message wins.
14
+
15
+ Subject context and live context are recomputed for the current turn and are more authoritative for state-sensitive facts.
16
+
17
+ The original messages remain durably stored; this summary only affects the model-visible prompt projection.
18
+ TEXT
19
+
5
20
  def self.for(messages)
6
- messages.map { |message| new(message).to_h }
21
+ messages.flat_map { |message| new(message).to_a }
7
22
  end
8
23
 
9
24
  def initialize(message)
10
25
  @message = message
11
26
  end
12
27
 
28
+ def to_a
29
+ case message.kind
30
+ when "context_summary"
31
+ [
32
+ { role: :user, content: CONTEXT_SUMMARY_TRIGGER },
33
+ { role: :assistant, content: [ CONTEXT_SUMMARY_PREFIX, message.text ].reject(&:empty?).join("\n\n") }
34
+ ]
35
+ else
36
+ [ to_h ]
37
+ end
38
+ end
39
+
13
40
  def to_h
14
41
  case message.kind
15
42
  when "tool_call"
data/lib/turnkit/turn.rb CHANGED
@@ -6,7 +6,7 @@ module TurnKit
6
6
 
7
7
  attr_reader :agent, :conversation, :store, :budget, :depth
8
8
  attr_reader :id, :conversation_id, :agent_name, :parent_turn_id, :parent_tool_execution_id
9
- attr_reader :root_turn_id, :context_message_sequence, :model, :thinking
9
+ attr_reader :root_turn_id, :context_message_sequence, :model, :thinking, :compact
10
10
  attr_reader :started_at
11
11
 
12
12
  def initialize(agent:, conversation:, record:, store:, budget: nil, depth: 0)
@@ -23,6 +23,7 @@ module TurnKit
23
23
  @context_message_sequence = @record["context_message_sequence"].to_i
24
24
  @model = @record["model"] || agent.effective_model
25
25
  @thinking = thinking_from_options
26
+ @compact = compact_from_options
26
27
  @started_at = @record["started_at"]
27
28
  @budget = budget || agent.build_budget
28
29
  @depth = depth
@@ -35,6 +36,7 @@ module TurnKit
35
36
  loop do
36
37
  budget.check!(depth: depth)
37
38
  budget.count_iteration!
39
+ TurnKit::Compaction.maybe_compact!(self)
38
40
 
39
41
  result = agent.effective_client.chat(
40
42
  model: model,
@@ -97,6 +99,7 @@ module TurnKit
97
99
  def reload
98
100
  @record = store.load_turn(id)
99
101
  @thinking = thinking_from_options
102
+ @compact = compact_from_options
100
103
  self
101
104
  end
102
105
 
@@ -106,7 +109,7 @@ module TurnKit
106
109
 
107
110
  private
108
111
  def llm_messages
109
- MessageProjection.for(conversation.messages_for_turn(self))
112
+ MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
110
113
  end
111
114
 
112
115
  def thinking_from_options
@@ -116,6 +119,11 @@ module TurnKit
116
119
  agent.effective_thinking
117
120
  end
118
121
 
122
+ def compact_from_options
123
+ options = (@record["options"] || {}).transform_keys(&:to_s)
124
+ options["compact"] if options.key?("compact")
125
+ end
126
+
119
127
  def persist_assistant_message(result)
120
128
  if result.tool_calls?
121
129
  conversation.append_message(
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.2.5"
4
+ VERSION = "0.2.6"
5
5
  end
data/lib/turnkit.rb CHANGED
@@ -25,6 +25,7 @@ require_relative "turnkit/prompt_contribution"
25
25
  require_relative "turnkit/system_prompt"
26
26
  require_relative "turnkit/store"
27
27
  require_relative "turnkit/memory_store"
28
+ require_relative "turnkit/compaction"
28
29
  require_relative "turnkit/tool"
29
30
  require_relative "turnkit/tool_call"
30
31
  require_relative "turnkit/tool_execution"
@@ -43,6 +44,7 @@ module TurnKit
43
44
  attr_accessor :default_model, :client, :store, :logger
44
45
  attr_accessor :max_iterations, :timeout, :max_depth, :max_tool_executions
45
46
  attr_accessor :cost_limit, :prompt_cache
47
+ attr_accessor :compaction
46
48
  attr_accessor :cost_rates, :cost_calculator
47
49
  attr_accessor :prompt_sections, :prompt_behavior, :available_skills
48
50
  attr_accessor :prompt_data_max_chars, :context_contributors
@@ -59,6 +61,7 @@ module TurnKit
59
61
  self.max_depth = 3
60
62
  self.max_tool_executions = 100
61
63
  self.prompt_cache = :auto
64
+ self.compaction = true
62
65
  self.cost_rates = {}
63
66
  self.prompt_sections = SystemPrompt::DEFAULT_SECTIONS.dup
64
67
  self.prompt_data_max_chars = 20_000
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turnkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-06 00:00:00.000000000 Z
11
+ date: 2026-06-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -42,6 +42,7 @@ files:
42
42
  - lib/turnkit/budget.rb
43
43
  - lib/turnkit/client.rb
44
44
  - lib/turnkit/clock.rb
45
+ - lib/turnkit/compaction.rb
45
46
  - lib/turnkit/conversation.rb
46
47
  - lib/turnkit/cost.rb
47
48
  - lib/turnkit/error.rb