turnkit 0.2.5 → 0.2.7
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 +155 -321
- data/lib/turnkit/adapters/ruby_llm.rb +69 -5
- data/lib/turnkit/agent.rb +20 -2
- data/lib/turnkit/client.rb +5 -1
- data/lib/turnkit/compaction.rb +406 -0
- data/lib/turnkit/conversation.rb +11 -4
- data/lib/turnkit/error.rb +3 -0
- data/lib/turnkit/event.rb +25 -0
- data/lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb +1 -0
- data/lib/turnkit/generators/turnkit/install/templates/initializer.rb +6 -0
- data/lib/turnkit/generators/turnkit/install_generator.rb +6 -0
- data/lib/turnkit/message.rb +21 -1
- data/lib/turnkit/message_projection.rb +28 -1
- data/lib/turnkit/model_request.rb +35 -0
- data/lib/turnkit/record.rb +2 -1
- data/lib/turnkit/result.rb +3 -2
- data/lib/turnkit/stores/active_record_store.rb +14 -4
- data/lib/turnkit/sub_agent_tool.rb +13 -4
- data/lib/turnkit/tool.rb +117 -4
- data/lib/turnkit/tool_call.rb +3 -1
- data/lib/turnkit/tool_runner.rb +8 -1
- data/lib/turnkit/turn.rb +101 -16
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +7 -0
- metadata +8 -6
|
@@ -3,7 +3,28 @@
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
module Adapters
|
|
5
5
|
class RubyLLM < Client
|
|
6
|
-
|
|
6
|
+
KEY_BY_PROVIDER = {
|
|
7
|
+
openai: "OPENAI_API_KEY",
|
|
8
|
+
gemini: "GEMINI_API_KEY",
|
|
9
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
10
|
+
openrouter: "OPENROUTER_API_KEY"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def validate!(model:)
|
|
14
|
+
require "ruby_llm"
|
|
15
|
+
|
|
16
|
+
raise ModelAccessError, "model is required" if model.to_s.empty?
|
|
17
|
+
|
|
18
|
+
configure_from_environment
|
|
19
|
+
provider = provider_for(model)
|
|
20
|
+
key_name = KEY_BY_PROVIDER[provider]
|
|
21
|
+
return true unless key_name
|
|
22
|
+
return true if ENV[key_name].to_s != "" || config_key_present?(provider)
|
|
23
|
+
|
|
24
|
+
raise ModelAccessError, "#{key_name} is required for #{model}. Set ENV[#{key_name.inspect}] or configure RubyLLM before running TurnKit."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
|
|
7
28
|
require "ruby_llm"
|
|
8
29
|
|
|
9
30
|
configure_from_environment
|
|
@@ -12,6 +33,7 @@ module TurnKit
|
|
|
12
33
|
add_instructions(chat, instructions, model: model)
|
|
13
34
|
chat.with_temperature(temperature) if temperature
|
|
14
35
|
apply_thinking(chat, thinking)
|
|
36
|
+
chat.with_schema(normalize_schema(output_schema)) if output_schema
|
|
15
37
|
Array(tools).each { |tool| chat.with_tool(ruby_llm_tool(tool)) }
|
|
16
38
|
Array(messages).each { |message| add_message(chat, message) }
|
|
17
39
|
|
|
@@ -28,11 +50,39 @@ module TurnKit
|
|
|
28
50
|
config.openrouter_api_key ||= ENV["OPENROUTER_API_KEY"]
|
|
29
51
|
end
|
|
30
52
|
|
|
53
|
+
def provider_for(model)
|
|
54
|
+
value = model.to_s.downcase
|
|
55
|
+
return :openrouter if value.start_with?("openrouter/")
|
|
56
|
+
return :anthropic if value.start_with?("anthropic/", "claude")
|
|
57
|
+
return :gemini if value.start_with?("gemini/", "gemini")
|
|
58
|
+
return :openai if value.start_with?("openai/", "gpt", "o1", "o3", "o4")
|
|
59
|
+
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def config_key_present?(provider)
|
|
64
|
+
value = ::RubyLLM.config.public_send("#{provider}_api_key") if ::RubyLLM.config.respond_to?("#{provider}_api_key")
|
|
65
|
+
value.to_s != ""
|
|
66
|
+
end
|
|
67
|
+
|
|
31
68
|
def apply_thinking(chat, thinking)
|
|
32
69
|
thinking = Agent.normalize_thinking(thinking)
|
|
33
70
|
chat.with_thinking(**thinking) if thinking
|
|
34
71
|
end
|
|
35
72
|
|
|
73
|
+
def normalize_schema(schema)
|
|
74
|
+
case schema
|
|
75
|
+
when Hash
|
|
76
|
+
normalized = schema.transform_keys(&:to_s).transform_values { |value| normalize_schema(value) }
|
|
77
|
+
normalized["additionalProperties"] = false if normalized["type"] == "object" && !normalized.key?("additionalProperties")
|
|
78
|
+
normalized
|
|
79
|
+
when Array
|
|
80
|
+
schema.map { |value| normalize_schema(value) }
|
|
81
|
+
else
|
|
82
|
+
schema
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
36
86
|
def complete_without_tool_execution(chat)
|
|
37
87
|
provider = chat.instance_variable_get(:@provider)
|
|
38
88
|
provider.complete(
|
|
@@ -110,9 +160,7 @@ module TurnKit
|
|
|
110
160
|
Class.new(::RubyLLM::Tool) do
|
|
111
161
|
define_singleton_method(:name) { tool.tool_name }
|
|
112
162
|
description tool.description
|
|
113
|
-
tool.
|
|
114
|
-
param(param.fetch(:name).to_sym, type: param.fetch(:type), required: param.fetch(:required), desc: param.fetch(:description))
|
|
115
|
-
end
|
|
163
|
+
params tool.input_schema
|
|
116
164
|
|
|
117
165
|
define_method(:execute) do |**arguments|
|
|
118
166
|
raise ToolError, "tools must be executed by TurnKit turns, not the RubyLLM adapter"
|
|
@@ -133,13 +181,29 @@ module TurnKit
|
|
|
133
181
|
cost: response_cost(response)
|
|
134
182
|
)
|
|
135
183
|
Result.new(
|
|
136
|
-
text: response
|
|
184
|
+
text: response_text(response),
|
|
185
|
+
output_data: response_data(response),
|
|
137
186
|
tool_calls: tool_calls,
|
|
138
187
|
usage: usage,
|
|
139
188
|
model: response.respond_to?(:model_id) ? response.model_id : model
|
|
140
189
|
)
|
|
141
190
|
end
|
|
142
191
|
|
|
192
|
+
def response_text(response)
|
|
193
|
+
content = response.respond_to?(:content) ? response.content : response
|
|
194
|
+
content.is_a?(Hash) || content.is_a?(Array) ? content.to_json : content.to_s
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def response_data(response)
|
|
198
|
+
content = response.respond_to?(:content) ? response.content : nil
|
|
199
|
+
return content if content.is_a?(Hash) || content.is_a?(Array)
|
|
200
|
+
return nil unless content.is_a?(String)
|
|
201
|
+
|
|
202
|
+
JSON.parse(content)
|
|
203
|
+
rescue JSON::ParserError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
143
207
|
def token_value(response, method)
|
|
144
208
|
response.respond_to?(method) ? response.public_send(method).to_i : 0
|
|
145
209
|
end
|
data/lib/turnkit/agent.rb
CHANGED
|
@@ -4,11 +4,12 @@ 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, :output_schema, :on_event
|
|
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
|
+
output_schema: nil, on_event: nil)
|
|
12
13
|
@name = name.to_s
|
|
13
14
|
@description = description.to_s
|
|
14
15
|
@model = model
|
|
@@ -28,7 +29,11 @@ module TurnKit
|
|
|
28
29
|
@max_depth = max_depth
|
|
29
30
|
@max_tool_executions = max_tool_executions
|
|
30
31
|
@thinking = self.class.normalize_thinking(thinking)
|
|
32
|
+
@compaction = compaction
|
|
33
|
+
@output_schema = output_schema
|
|
34
|
+
@on_event = on_event
|
|
31
35
|
raise ArgumentError, "name is required" if @name.empty?
|
|
36
|
+
validate_tools!
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
def self.normalize_thinking(value)
|
|
@@ -85,6 +90,10 @@ module TurnKit
|
|
|
85
90
|
tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
|
|
86
91
|
end
|
|
87
92
|
|
|
93
|
+
def effective_on_event
|
|
94
|
+
on_event || TurnKit.on_event
|
|
95
|
+
end
|
|
96
|
+
|
|
88
97
|
def effective_available_skills
|
|
89
98
|
(Array(TurnKit.available_skills) + available_skills).uniq { |skill| skill.key }
|
|
90
99
|
end
|
|
@@ -128,5 +137,14 @@ module TurnKit
|
|
|
128
137
|
parts << SystemPrompt.loaded_skills_text(skills)
|
|
129
138
|
parts.reject(&:empty?).join("\n\n")
|
|
130
139
|
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
def validate_tools!
|
|
143
|
+
names = effective_tools.map(&:tool_name)
|
|
144
|
+
duplicate = names.find { |name| names.count(name) > 1 }
|
|
145
|
+
raise ArgumentError, "duplicate tool name: #{duplicate}" if duplicate
|
|
146
|
+
|
|
147
|
+
effective_tools.each(&:validate_definition!)
|
|
148
|
+
end
|
|
131
149
|
end
|
|
132
150
|
end
|
data/lib/turnkit/client.rb
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Client
|
|
5
|
-
def
|
|
5
|
+
def validate!(model:)
|
|
6
|
+
true
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
|
|
6
10
|
raise NotImplementedError
|
|
7
11
|
end
|
|
8
12
|
end
|
|
@@ -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,17 @@ 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, output_schema: nil, on_event: 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, output_schema: output_schema, on_event: on_event).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, output_schema: nil, on_event: 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?
|
|
39
|
+
options["output_schema"] = output_schema || agent.output_schema if output_schema || agent.output_schema
|
|
38
40
|
record = store.create_turn(
|
|
39
41
|
"conversation_id" => id,
|
|
40
42
|
"agent_name" => agent.name,
|
|
@@ -46,7 +48,12 @@ module TurnKit
|
|
|
46
48
|
"model" => model || self.model || agent.effective_model,
|
|
47
49
|
"options" => options
|
|
48
50
|
)
|
|
49
|
-
Turn.new(agent: agent, conversation: self, record: record, store: store, budget: budget, depth: depth)
|
|
51
|
+
Turn.new(agent: agent, conversation: self, record: record, store: store, budget: budget, depth: depth, on_event: on_event)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def compact!(focus: nil, model: nil)
|
|
55
|
+
overrides = { "model" => model }.compact
|
|
56
|
+
TurnKit::Compaction.compact!(self, agent: agent, focus: focus, auto: false, overrides: overrides)
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
def messages
|
data/lib/turnkit/error.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
class ConfigError < Error; end
|
|
6
|
+
class CompactionError < Error; end
|
|
7
|
+
class ModelAccessError < ConfigError; end
|
|
6
8
|
class StoreError < Error; end
|
|
7
9
|
class ToolError < Error; end
|
|
10
|
+
class ToolValidationError < ToolError; end
|
|
8
11
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
class Event
|
|
5
|
+
attr_reader :type, :turn_id, :conversation_id, :payload, :created_at
|
|
6
|
+
|
|
7
|
+
def initialize(type:, turn_id:, conversation_id:, payload: {}, created_at: Clock.now)
|
|
8
|
+
@type = type.to_s
|
|
9
|
+
@turn_id = turn_id
|
|
10
|
+
@conversation_id = conversation_id
|
|
11
|
+
@payload = payload || {}
|
|
12
|
+
@created_at = created_at
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{
|
|
17
|
+
"type" => type,
|
|
18
|
+
"turn_id" => turn_id,
|
|
19
|
+
"conversation_id" => conversation_id,
|
|
20
|
+
"payload" => payload,
|
|
21
|
+
"created_at" => created_at
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -12,8 +12,14 @@ TurnKit.tool_execution_record_class = "Turnkit::ToolExecution"
|
|
|
12
12
|
# TurnKit.timeout = 300
|
|
13
13
|
# TurnKit.max_depth = 3
|
|
14
14
|
# TurnKit.max_tool_executions = 100
|
|
15
|
+
# TurnKit.on_event = ->(event) { Rails.logger.info("turnkit.#{event.type} #{event.payload.inspect}") }
|
|
15
16
|
|
|
16
17
|
# TurnKit builds each system prompt from these sections by default.
|
|
17
18
|
# TurnKit.prompt_sections = %i[agent instructions behavior loaded_skills available_skills tools subject environment]
|
|
18
19
|
# TurnKit.prompt_behavior = "Custom behavior instructions."
|
|
19
20
|
# TurnKit.available_skills = TurnKit::Skill.from_directory(Rails.root.join("app/ai/skills"))
|
|
21
|
+
|
|
22
|
+
# Suggested Rails convention:
|
|
23
|
+
# - app/ai/agents/* builds TurnKit::Agent objects for your workflows.
|
|
24
|
+
# - app/ai/tools/* defines TurnKit::Tool subclasses.
|
|
25
|
+
# - app/ai/skills/* stores reusable Markdown skill files.
|