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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +89 -0
- data/lib/turnkit/agent.rb +3 -2
- data/lib/turnkit/compaction.rb +406 -0
- data/lib/turnkit/conversation.rb +9 -3
- data/lib/turnkit/error.rb +1 -0
- data/lib/turnkit/message.rb +21 -1
- data/lib/turnkit/message_projection.rb +28 -1
- data/lib/turnkit/turn.rb +10 -2
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +3 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34429a11d156c9631705ec193c77c2ad166fb3dffc182a7b730cffd38b52f694
|
|
4
|
+
data.tar.gz: c497d2042388a33e80c037145e82a6adf1cc47286073441b7fb7f21fcd4a89b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/turnkit/conversation.rb
CHANGED
|
@@ -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
data/lib/turnkit/message.rb
CHANGED
|
@@ -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.
|
|
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(
|
data/lib/turnkit/version.rb
CHANGED
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.
|
|
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-
|
|
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
|