turnkit 0.4.1 → 0.5.0
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 +34 -0
- data/README.md +126 -51
- data/UPGRADE.md +29 -0
- data/UPGRADE_TO_0_4_2.md +425 -0
- data/lib/{turnkit/generators → generators}/turnkit/install/templates/initializer.rb +7 -6
- data/lib/turnkit/{stores/active_record_store.rb → active_record_store.rb} +34 -10
- data/lib/turnkit/adapters/codex.rb +8 -11
- data/lib/turnkit/adapters/ruby_llm.rb +30 -46
- data/lib/turnkit/agent.rb +43 -10
- data/lib/turnkit/budget.rb +1 -1
- data/lib/turnkit/client.rb +5 -1
- data/lib/turnkit/compaction.rb +46 -46
- data/lib/turnkit/cost.rb +29 -31
- data/lib/turnkit/image_result.rb +1 -0
- data/lib/turnkit/image_tool.rb +2 -2
- data/lib/turnkit/media_analysis_result.rb +0 -2
- data/lib/turnkit/memory_store.rb +11 -6
- data/lib/turnkit/model_request.rb +4 -2
- data/lib/turnkit/output_policy.rb +1 -15
- data/lib/turnkit/reconciliation.rb +72 -0
- data/lib/turnkit/record.rb +1 -1
- data/lib/turnkit/run.rb +0 -4
- data/lib/turnkit/store.rb +17 -8
- data/lib/turnkit/sub_agent_tool.rb +9 -14
- data/lib/turnkit/system_prompt.rb +32 -96
- data/lib/turnkit/tool.rb +5 -16
- data/lib/turnkit/tool_runner.rb +27 -3
- data/lib/turnkit/turn.rb +73 -89
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit/view_media_tool.rb +2 -2
- data/lib/turnkit.rb +3 -21
- metadata +16 -30
- data/lib/turnkit/prompt_contribution.rb +0 -13
- data/lib/turnkit/rails/railtie.rb +0 -9
- data/lib/turnkit/workflow.rb +0 -58
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/conversation.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/create_turnkit_tables.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/message.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/tool_execution.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/turn.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install_generator.rb +0 -0
|
@@ -11,8 +11,7 @@ module TurnKit
|
|
|
11
11
|
}.freeze
|
|
12
12
|
|
|
13
13
|
def validate!(model:)
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
ensure_ruby_llm!
|
|
16
15
|
raise ModelAccessError, "model is required" if model.to_s.empty?
|
|
17
16
|
|
|
18
17
|
configure_from_environment
|
|
@@ -24,13 +23,12 @@ module TurnKit
|
|
|
24
23
|
raise ModelAccessError, "#{key_name} is required for #{model}. Set ENV[#{key_name.inspect}] or configure RubyLLM before running TurnKit."
|
|
25
24
|
end
|
|
26
25
|
|
|
27
|
-
def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
def chat(model:, messages:, tools:, instructions:, dynamic_instructions: nil, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
|
|
27
|
+
ensure_ruby_llm!
|
|
30
28
|
configure_from_environment
|
|
31
29
|
|
|
32
30
|
chat = ::RubyLLM.chat(model: model)
|
|
33
|
-
add_instructions(chat, instructions, model: model)
|
|
31
|
+
add_instructions(chat, instructions, dynamic_instructions, model: model)
|
|
34
32
|
chat.with_temperature(temperature) if temperature
|
|
35
33
|
apply_thinking(chat, thinking)
|
|
36
34
|
chat.with_schema(normalize_schema(output_schema)) if output_schema
|
|
@@ -42,10 +40,10 @@ module TurnKit
|
|
|
42
40
|
end
|
|
43
41
|
|
|
44
42
|
def paint(prompt:, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: nil, on_event: nil)
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
ensure_ruby_llm!
|
|
47
44
|
configure_from_environment
|
|
48
|
-
|
|
45
|
+
image = ::RubyLLM.paint(
|
|
46
|
+
prompt,
|
|
49
47
|
model: model,
|
|
50
48
|
provider: provider,
|
|
51
49
|
assume_model_exists: assume_model_exists || false,
|
|
@@ -54,13 +52,11 @@ module TurnKit
|
|
|
54
52
|
mask: mask,
|
|
55
53
|
params: params || {}
|
|
56
54
|
)
|
|
57
|
-
image = ::RubyLLM.paint(prompt, **kwargs)
|
|
58
55
|
normalize_image_response(image, model: model, provider: provider, params: { "size" => size || "1024x1024" }.merge(params || {}), metadata: metadata)
|
|
59
56
|
end
|
|
60
57
|
|
|
61
58
|
def view_media(media:, objective:, model:, provider: nil, output_schema: nil, params: {}, metadata: nil, on_event: nil)
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
ensure_ruby_llm!
|
|
64
60
|
configure_from_environment
|
|
65
61
|
media_input = MediaInput.wrap(media)
|
|
66
62
|
content = ::RubyLLM::Content.new(objective.to_s)
|
|
@@ -76,6 +72,12 @@ module TurnKit
|
|
|
76
72
|
end
|
|
77
73
|
|
|
78
74
|
private
|
|
75
|
+
def ensure_ruby_llm!
|
|
76
|
+
require "ruby_llm"
|
|
77
|
+
rescue LoadError
|
|
78
|
+
raise ConfigError, "TurnKit::Adapters::RubyLLM requires the ruby_llm gem (>= 1.16). Add `gem \"ruby_llm\"` to your Gemfile."
|
|
79
|
+
end
|
|
80
|
+
|
|
79
81
|
def configure_from_environment
|
|
80
82
|
config = ::RubyLLM.config
|
|
81
83
|
config.openai_api_key ||= ENV["OPENAI_API_KEY"]
|
|
@@ -117,19 +119,17 @@ module TurnKit
|
|
|
117
119
|
end
|
|
118
120
|
end
|
|
119
121
|
|
|
122
|
+
# RubyLLM has no public API to request a completion without executing
|
|
123
|
+
# tool calls, so this depends on the private RubyLLM::Chat#provider_completion
|
|
124
|
+
# (added in ruby_llm 1.16). TurnKit must run tools itself (to persist
|
|
125
|
+
# executions and enforce budgets). Guarded by a canary test in the
|
|
126
|
+
# suite; revisit when RubyLLM exposes a public equivalent.
|
|
120
127
|
def complete_without_tool_execution(chat)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
temperature: chat.instance_variable_get(:@temperature),
|
|
127
|
-
model: chat.model,
|
|
128
|
-
params: chat.params,
|
|
129
|
-
headers: chat.headers,
|
|
130
|
-
schema: chat.schema,
|
|
131
|
-
thinking: chat.instance_variable_get(:@thinking)
|
|
132
|
-
)
|
|
128
|
+
unless chat.respond_to?(:provider_completion, true)
|
|
129
|
+
raise ConfigError, "TurnKit::Adapters::RubyLLM requires ruby_llm >= 1.16 (RubyLLM::Chat#provider_completion not found)"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
chat.send(:provider_completion)
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
def add_message(chat, message)
|
|
@@ -145,15 +145,16 @@ module TurnKit
|
|
|
145
145
|
)
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
-
def add_instructions(chat, instructions, model:)
|
|
149
|
-
|
|
148
|
+
def add_instructions(chat, instructions, dynamic_instructions, model:)
|
|
149
|
+
stable = instructions.to_s
|
|
150
|
+
dynamic = dynamic_instructions.to_s
|
|
151
|
+
return if stable.empty? && dynamic.empty?
|
|
150
152
|
|
|
151
|
-
if prompt_cache_enabled? && anthropic_model?(model) &&
|
|
152
|
-
stable, dynamic = SystemPrompt.split_cache_boundary(instructions)
|
|
153
|
+
if prompt_cache_enabled? && anthropic_model?(model) && !dynamic.empty?
|
|
153
154
|
add_system_message(chat, stable, cache: true)
|
|
154
155
|
add_system_message(chat, dynamic, cache: false)
|
|
155
156
|
else
|
|
156
|
-
chat.with_instructions(
|
|
157
|
+
chat.with_instructions([ stable, dynamic ].reject(&:empty?).join("\n\n"))
|
|
157
158
|
end
|
|
158
159
|
end
|
|
159
160
|
|
|
@@ -189,8 +190,6 @@ module TurnKit
|
|
|
189
190
|
end
|
|
190
191
|
|
|
191
192
|
def ruby_llm_tool(tool)
|
|
192
|
-
require "ruby_llm"
|
|
193
|
-
|
|
194
193
|
Class.new(::RubyLLM::Tool) do
|
|
195
194
|
define_singleton_method(:name) { tool.tool_name }
|
|
196
195
|
description tool.description
|
|
@@ -281,21 +280,6 @@ module TurnKit
|
|
|
281
280
|
response.cost&.total
|
|
282
281
|
end
|
|
283
282
|
|
|
284
|
-
def paint_kwargs(kwargs)
|
|
285
|
-
parameters = ::RubyLLM::Image.method(:paint).parameters
|
|
286
|
-
return kwargs if parameters.any? { |kind, _| kind == :keyrest }
|
|
287
|
-
|
|
288
|
-
accepted = parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) }
|
|
289
|
-
unsupported = kwargs.keys.select { |key| !accepted.include?(key) && !blank?(kwargs[key]) }
|
|
290
|
-
raise ArgumentError, "RubyLLM image generation does not support: #{unsupported.join(", ")}" if unsupported.any?
|
|
291
|
-
|
|
292
|
-
kwargs.slice(*accepted)
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
def blank?(value)
|
|
296
|
-
value.nil? || value == false || (value.respond_to?(:empty?) && value.empty?)
|
|
297
|
-
end
|
|
298
|
-
|
|
299
283
|
def normalize_image_response(image, model:, provider:, params:, metadata:)
|
|
300
284
|
usage = Usage.new(
|
|
301
285
|
input_tokens: image_usage_value(image, "input_tokens"),
|
data/lib/turnkit/agent.rb
CHANGED
|
@@ -2,26 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Agent
|
|
5
|
+
ORCHESTRATOR_PREAMBLE = <<~TEXT.strip
|
|
6
|
+
You are an autonomous task orchestrator. Navigate from the application
|
|
7
|
+
request to a final output without asking the user follow-up questions.
|
|
8
|
+
|
|
9
|
+
Use the available tools to gather context, inspect sources, take actions,
|
|
10
|
+
persist outputs, and verify work. Use loaded skills as reusable workflow
|
|
11
|
+
patterns. Iterate when work needs missing context, critique, revision, or
|
|
12
|
+
verification.
|
|
13
|
+
|
|
14
|
+
When multiple independent items need the same kind of fetch or read, and
|
|
15
|
+
an available batch tool can handle them in one call, prefer the batch tool
|
|
16
|
+
over repeated one-item tool calls.
|
|
17
|
+
|
|
18
|
+
Stop when the task is complete, when the available context and tools are
|
|
19
|
+
sufficient for the best possible answer, or when further iteration would
|
|
20
|
+
not materially improve the result. Respect runtime, cost, and iteration
|
|
21
|
+
limits.
|
|
22
|
+
TEXT
|
|
23
|
+
|
|
5
24
|
attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
|
|
6
25
|
attr_reader :client, :store, :max_iterations, :timeout, :max_spend, :max_depth, :max_tool_executions, :max_tool_executions_by_name
|
|
7
26
|
attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :input_schema, :on_event
|
|
8
27
|
attr_reader :output_policy, :output_policy_mode, :output_policy_model, :output_retries
|
|
9
28
|
|
|
10
|
-
def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], available_skills: [], sub_agents: [],
|
|
29
|
+
def initialize(name:, description: "", model: nil, instructions: "", orchestrator: false, tools: [], skills: [], available_skills: [], sub_agents: [],
|
|
11
30
|
system_prompt: nil, prompt_sections: nil, prompt_mode: nil, client: nil, store: nil,
|
|
12
31
|
max_iterations: nil, timeout: nil, max_spend: nil, max_depth: nil, max_tool_executions: nil, max_tool_executions_by_name: nil, thinking: nil, compaction: nil,
|
|
13
32
|
output_schema: nil, input_schema: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, output_retries: 0, on_event: nil)
|
|
14
33
|
@name = name.to_s
|
|
15
34
|
@description = description.to_s
|
|
16
35
|
@model = model
|
|
17
|
-
@
|
|
36
|
+
@orchestrator = orchestrator ? true : false
|
|
37
|
+
@instructions = compose_instructions(instructions)
|
|
18
38
|
@tools = Array(tools)
|
|
19
39
|
@skills = Array(skills)
|
|
20
40
|
@available_skills = Array(available_skills)
|
|
21
41
|
@sub_agents = Array(sub_agents)
|
|
22
42
|
@system_prompt = system_prompt
|
|
23
43
|
@prompt_sections = prompt_sections
|
|
24
|
-
@prompt_mode = prompt_mode&.to_sym
|
|
44
|
+
@prompt_mode = prompt_mode&.to_sym || (:task if @orchestrator)
|
|
25
45
|
@client = client
|
|
26
46
|
@store = store
|
|
27
47
|
@max_iterations = max_iterations
|
|
@@ -69,8 +89,11 @@ module TurnKit
|
|
|
69
89
|
Conversation.new(agent: self, record: record, store: store, model: model || effective_model, subject: subject, metadata: metadata)
|
|
70
90
|
end
|
|
71
91
|
|
|
72
|
-
def
|
|
73
|
-
|
|
92
|
+
def orchestrator?
|
|
93
|
+
@orchestrator
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def run(task, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, prompt_mode: :task, **options)
|
|
74
97
|
raise ArgumentError, "task is required" if task.to_s.empty?
|
|
75
98
|
SchemaCheck.validate!(input, input_schema, error_class: InputError, label: "input") if input_schema
|
|
76
99
|
|
|
@@ -151,16 +174,19 @@ module TurnKit
|
|
|
151
174
|
end
|
|
152
175
|
end
|
|
153
176
|
|
|
154
|
-
def
|
|
155
|
-
|
|
177
|
+
def budget_limits
|
|
178
|
+
{
|
|
156
179
|
max_iterations: max_iterations || TurnKit.max_iterations,
|
|
157
180
|
timeout: timeout || TurnKit.timeout,
|
|
158
181
|
max_depth: max_depth || TurnKit.max_depth,
|
|
159
182
|
max_tool_executions: max_tool_executions || TurnKit.max_tool_executions,
|
|
160
183
|
max_tool_executions_by_name: max_tool_executions_by_name || TurnKit.max_tool_executions_by_name,
|
|
161
|
-
max_spend: max_spend || TurnKit.max_spend
|
|
162
|
-
|
|
163
|
-
|
|
184
|
+
max_spend: max_spend || TurnKit.max_spend
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def build_budget(root_started_at: Clock.now)
|
|
189
|
+
Budget.new(**budget_limits, root_started_at: root_started_at)
|
|
164
190
|
end
|
|
165
191
|
|
|
166
192
|
def instructions_with_skills
|
|
@@ -170,6 +196,13 @@ module TurnKit
|
|
|
170
196
|
end
|
|
171
197
|
|
|
172
198
|
private
|
|
199
|
+
def compose_instructions(instructions)
|
|
200
|
+
parts = []
|
|
201
|
+
parts << ORCHESTRATOR_PREAMBLE if @orchestrator
|
|
202
|
+
parts << instructions.to_s.strip unless instructions.to_s.strip.empty?
|
|
203
|
+
parts.join("\n\n")
|
|
204
|
+
end
|
|
205
|
+
|
|
173
206
|
def validate_tools!
|
|
174
207
|
effective_tools.each do |tool|
|
|
175
208
|
next if tool.is_a?(Class) && tool < Tool
|
data/lib/turnkit/budget.rb
CHANGED
|
@@ -29,7 +29,7 @@ module TurnKit
|
|
|
29
29
|
|
|
30
30
|
def seed!(turns:, tool_executions:)
|
|
31
31
|
@mutex.synchronize do
|
|
32
|
-
@iterations = Array(turns).sum { |turn| (turn
|
|
32
|
+
@iterations = Array(turns).sum { |turn| Turn.iterations_for(turn) }
|
|
33
33
|
completed = Array(tool_executions).select { |execution| %w[completed failed].include?(execution["status"]) && !execution.dig("error", "details", "budget_denied") }
|
|
34
34
|
@tool_executions = completed.length
|
|
35
35
|
completed.each { |execution| @tool_executions_by_name[execution.fetch("tool_name").to_s] += 1 }
|
data/lib/turnkit/client.rb
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
|
+
# The adapter contract. TurnKit calls clients with the full keyword
|
|
5
|
+
# signatures below. Custom adapters should subclass TurnKit::Client (or
|
|
6
|
+
# accept the same keywords) and must not execute tools themselves; TurnKit
|
|
7
|
+
# runs tools and persists their results.
|
|
4
8
|
class Client
|
|
5
9
|
def validate!(model:)
|
|
6
10
|
true
|
|
7
11
|
end
|
|
8
12
|
|
|
9
|
-
def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
|
|
13
|
+
def chat(model:, messages:, tools:, instructions:, dynamic_instructions: nil, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
|
|
10
14
|
raise NotImplementedError
|
|
11
15
|
end
|
|
12
16
|
|
data/lib/turnkit/compaction.rb
CHANGED
|
@@ -3,19 +3,19 @@
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
module Compaction
|
|
5
5
|
DEFAULTS = {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
19
|
}.freeze
|
|
20
20
|
|
|
21
21
|
KNOWN_KEYS = DEFAULTS.keys.freeze
|
|
@@ -92,7 +92,7 @@ module TurnKit
|
|
|
92
92
|
module_function
|
|
93
93
|
|
|
94
94
|
def enabled_for?(agent, overrides = {})
|
|
95
|
-
policy_for(agent, overrides)[
|
|
95
|
+
policy_for(agent, overrides)[:enabled]
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def policy_for(agent, overrides = {})
|
|
@@ -100,9 +100,9 @@ module TurnKit
|
|
|
100
100
|
local = normalize_config(agent.compaction)
|
|
101
101
|
override = normalize_config(overrides)
|
|
102
102
|
|
|
103
|
-
return DEFAULTS.merge(
|
|
104
|
-
return DEFAULTS.merge(
|
|
105
|
-
return DEFAULTS.merge(
|
|
103
|
+
return DEFAULTS.merge(enabled: false) if global == false
|
|
104
|
+
return DEFAULTS.merge(enabled: false) if local == false
|
|
105
|
+
return DEFAULTS.merge(enabled: false) if override == false
|
|
106
106
|
|
|
107
107
|
DEFAULTS.merge(global || {}).merge(local || {}).merge(override || {})
|
|
108
108
|
end
|
|
@@ -112,7 +112,7 @@ module TurnKit
|
|
|
112
112
|
|
|
113
113
|
force = turn.compact == true if force.nil?
|
|
114
114
|
policy = policy_for(turn.agent)
|
|
115
|
-
return unless policy[
|
|
115
|
+
return unless policy[:enabled]
|
|
116
116
|
|
|
117
117
|
messages = project(turn.conversation.messages_for_turn(turn))
|
|
118
118
|
return unless force || over_threshold?(messages, policy)
|
|
@@ -127,7 +127,7 @@ module TurnKit
|
|
|
127
127
|
|
|
128
128
|
def compact!(conversation, agent:, turn: nil, focus: nil, auto: false, overrides: {}, force: true)
|
|
129
129
|
policy = policy_for(agent, overrides)
|
|
130
|
-
raise CompactionError, "compaction is disabled" unless policy[
|
|
130
|
+
raise CompactionError, "compaction is disabled" unless policy[:enabled]
|
|
131
131
|
|
|
132
132
|
messages = turn ? conversation.messages_for_turn(turn) : conversation.messages
|
|
133
133
|
projected = project(messages)
|
|
@@ -135,14 +135,14 @@ module TurnKit
|
|
|
135
135
|
return nil if selected.nil? && auto
|
|
136
136
|
raise CompactionError, "not enough messages to compact" unless selected
|
|
137
137
|
|
|
138
|
-
selected_tokens = estimate_messages_tokens(selected.fetch(
|
|
138
|
+
selected_tokens = estimate_messages_tokens(selected.fetch(:middle))
|
|
139
139
|
return nil if auto && !force && !over_threshold?(projected, policy)
|
|
140
140
|
|
|
141
141
|
summary = generate_summary(
|
|
142
142
|
agent: agent,
|
|
143
143
|
policy: policy,
|
|
144
|
-
messages: selected.fetch(
|
|
145
|
-
previous_summary: selected[
|
|
144
|
+
messages: selected.fetch(:middle),
|
|
145
|
+
previous_summary: selected[:previous_summary]&.text,
|
|
146
146
|
focus: focus,
|
|
147
147
|
target_tokens: summary_budget(selected_tokens, policy),
|
|
148
148
|
fallback_model: turn&.model || conversation.model || agent.effective_model,
|
|
@@ -209,25 +209,25 @@ module TurnKit
|
|
|
209
209
|
end
|
|
210
210
|
|
|
211
211
|
def summary_budget(input_tokens, policy)
|
|
212
|
-
budget = (input_tokens.to_i * policy[
|
|
213
|
-
budget = [ budget, policy[
|
|
214
|
-
[ budget, policy[
|
|
212
|
+
budget = (input_tokens.to_i * policy[:summary_ratio].to_f).ceil
|
|
213
|
+
budget = [ budget, policy[:min_summary_tokens].to_i ].max
|
|
214
|
+
[ budget, policy[:max_summary_tokens].to_i ].min
|
|
215
215
|
end
|
|
216
216
|
|
|
217
217
|
def over_threshold?(messages, policy)
|
|
218
|
-
usable = [ policy[
|
|
219
|
-
estimate_messages_tokens(messages) >= (usable * policy[
|
|
218
|
+
usable = [ policy[:context_limit].to_i - policy[:reserved_tokens].to_i, 1 ].max
|
|
219
|
+
estimate_messages_tokens(messages) >= (usable * policy[:threshold].to_f)
|
|
220
220
|
end
|
|
221
221
|
|
|
222
222
|
def select_messages(messages, policy)
|
|
223
223
|
rows = Array(messages)
|
|
224
|
-
return nil if rows.length <= policy[
|
|
224
|
+
return nil if rows.length <= policy[:head_messages].to_i + 1
|
|
225
225
|
|
|
226
226
|
previous_summary = rows.reverse.find(&:context_summary?)
|
|
227
227
|
candidates = rows.reject(&:context_summary?)
|
|
228
|
-
return nil if candidates.length <= policy[
|
|
228
|
+
return nil if candidates.length <= policy[:head_messages].to_i + 1
|
|
229
229
|
|
|
230
|
-
head_count = policy[
|
|
230
|
+
head_count = policy[:head_messages].to_i
|
|
231
231
|
tail_start = tail_start_index(candidates, policy)
|
|
232
232
|
tail_start = [ tail_start, head_count ].max
|
|
233
233
|
tail_start = expand_tail_start_for_tool_pairs(candidates, tail_start)
|
|
@@ -242,11 +242,11 @@ module TurnKit
|
|
|
242
242
|
end
|
|
243
243
|
|
|
244
244
|
{
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
245
|
+
middle: middle,
|
|
246
|
+
previous_summary: previous_summary,
|
|
247
|
+
replaces_from_sequence: from_sequence,
|
|
248
|
+
replaces_through_sequence: through_sequence,
|
|
249
|
+
tail_start_sequence: candidates[tail_start]&.sequence
|
|
250
250
|
}
|
|
251
251
|
end
|
|
252
252
|
|
|
@@ -290,7 +290,7 @@ module TurnKit
|
|
|
290
290
|
when false
|
|
291
291
|
false
|
|
292
292
|
when Hash
|
|
293
|
-
attrs = value.transform_keys(&:
|
|
293
|
+
attrs = value.transform_keys(&:to_sym)
|
|
294
294
|
unknown = attrs.keys - KNOWN_KEYS
|
|
295
295
|
raise ConfigError, "unknown compaction options: #{unknown.join(", ")}" if unknown.any?
|
|
296
296
|
|
|
@@ -323,8 +323,8 @@ module TurnKit
|
|
|
323
323
|
end
|
|
324
324
|
|
|
325
325
|
def tail_start_index(messages, policy)
|
|
326
|
-
max_messages = policy[
|
|
327
|
-
max_tokens = policy[
|
|
326
|
+
max_messages = policy[:tail_messages].to_i
|
|
327
|
+
max_tokens = policy[:tail_tokens].to_i
|
|
328
328
|
count = 0
|
|
329
329
|
tokens = 0
|
|
330
330
|
index = messages.length
|
|
@@ -357,8 +357,8 @@ module TurnKit
|
|
|
357
357
|
end
|
|
358
358
|
|
|
359
359
|
def generate_summary(agent:, policy:, messages:, previous_summary:, focus:, target_tokens:, fallback_model:, conversation_id:, turn_id:, turn: nil)
|
|
360
|
-
client = policy[
|
|
361
|
-
model = policy[
|
|
360
|
+
client = policy[:client] || agent.effective_client
|
|
361
|
+
model = policy[:model] || fallback_model
|
|
362
362
|
safe_messages = messages.map { |message| sanitize_message(message, policy) }
|
|
363
363
|
prompt = build_prompt(previous_summary: previous_summary, focus: focus, target_tokens: target_tokens)
|
|
364
364
|
attrs = {
|
|
@@ -369,7 +369,7 @@ module TurnKit
|
|
|
369
369
|
metadata: { compaction: true, conversation_id: conversation_id, turn_id: turn_id }
|
|
370
370
|
}
|
|
371
371
|
result = if turn
|
|
372
|
-
turn.internal_model_call(**attrs, purpose: "compaction", client: policy[
|
|
372
|
+
turn.internal_model_call(**attrs, purpose: "compaction", client: policy[:client])
|
|
373
373
|
else
|
|
374
374
|
client.validate!(model: model)
|
|
375
375
|
client.chat(**attrs)
|
|
@@ -383,7 +383,7 @@ module TurnKit
|
|
|
383
383
|
def sanitize_message(message, policy)
|
|
384
384
|
return message unless message.tool_result?
|
|
385
385
|
|
|
386
|
-
max = policy[
|
|
386
|
+
max = policy[:tool_output_max_chars].to_i
|
|
387
387
|
return message if max <= 0 || message.text.length <= max
|
|
388
388
|
|
|
389
389
|
attrs = message.to_h
|
|
@@ -392,7 +392,7 @@ module TurnKit
|
|
|
392
392
|
end
|
|
393
393
|
|
|
394
394
|
def append_summary(conversation, turn:, summary:, selected:, policy:, focus:, auto:, input_tokens:)
|
|
395
|
-
model = policy[
|
|
395
|
+
model = policy[:model] || turn&.model || conversation.model || conversation.agent.effective_model
|
|
396
396
|
conversation.append_message(
|
|
397
397
|
role: "assistant",
|
|
398
398
|
kind: "context_summary",
|
|
@@ -402,9 +402,9 @@ module TurnKit
|
|
|
402
402
|
"compaction" => {
|
|
403
403
|
"auto" => auto,
|
|
404
404
|
"focus" => focus,
|
|
405
|
-
"replaces_from_sequence" => selected.fetch(
|
|
406
|
-
"replaces_through_sequence" => selected.fetch(
|
|
407
|
-
"tail_start_sequence" => selected[
|
|
405
|
+
"replaces_from_sequence" => selected.fetch(:replaces_from_sequence),
|
|
406
|
+
"replaces_through_sequence" => selected.fetch(:replaces_through_sequence),
|
|
407
|
+
"tail_start_sequence" => selected[:tail_start_sequence],
|
|
408
408
|
"summary_model" => model,
|
|
409
409
|
"input_tokens" => input_tokens,
|
|
410
410
|
"summary_tokens" => estimate_text_tokens(summary),
|
data/lib/turnkit/cost.rb
CHANGED
|
@@ -48,42 +48,40 @@ module TurnKit
|
|
|
48
48
|
from_usage(Usage.from_h(usage), model: attrs["model"])
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
RATE_KEYS = %i[input output cache_read cache_write thinking].freeze
|
|
52
|
+
|
|
53
|
+
# Rates are USD per million tokens, keyed by component.
|
|
51
54
|
def self.from_rates(usage, rates)
|
|
52
55
|
rates = rates.transform_keys(&:to_sym)
|
|
56
|
+
unknown = rates.keys - RATE_KEYS
|
|
57
|
+
raise ConfigError, "unknown cost rate keys: #{unknown.join(", ")} (use: #{RATE_KEYS.join(", ")})" if unknown.any?
|
|
58
|
+
|
|
53
59
|
new(
|
|
54
|
-
input: amount(usage.input_tokens, rates[:input]
|
|
55
|
-
output: amount(usage.output_tokens, rates[:output]
|
|
56
|
-
cache_read: amount(usage.cached_tokens, rates[:cache_read]
|
|
57
|
-
cache_write: amount(usage.cache_write_tokens, rates[:cache_write]
|
|
58
|
-
thinking: amount(usage.thinking_tokens, rates[:thinking]
|
|
60
|
+
input: amount(usage.input_tokens, rates[:input]),
|
|
61
|
+
output: amount(usage.output_tokens, rates[:output]),
|
|
62
|
+
cache_read: amount(usage.cached_tokens, rates[:cache_read]),
|
|
63
|
+
cache_write: amount(usage.cache_write_tokens, rates[:cache_write]),
|
|
64
|
+
thinking: amount(usage.thinking_tokens, rates[:thinking]),
|
|
59
65
|
strict: true
|
|
60
66
|
)
|
|
61
67
|
end
|
|
62
68
|
|
|
69
|
+
# Estimates cost from RubyLLM's model pricing registry (ruby_llm >= 1.16).
|
|
70
|
+
# Returns an empty Cost when ruby_llm is not loaded or the model is not in
|
|
71
|
+
# the registry; any other failure raises.
|
|
63
72
|
def self.from_ruby_llm(usage, model)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
model_info = ::RubyLLM.models.find(model)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
77
|
-
from_hash(::RubyLLM::Cost.new(tokens: tokens, model: model_info).to_h)
|
|
78
|
-
else
|
|
79
|
-
from_rates(
|
|
80
|
-
usage,
|
|
81
|
-
input: model_info.input_price_per_million,
|
|
82
|
-
output: model_info.output_price_per_million,
|
|
83
|
-
cached_input: model_info.pricing&.text_tokens&.cached_input
|
|
84
|
-
)
|
|
85
|
-
end
|
|
86
|
-
rescue LoadError, StandardError
|
|
73
|
+
return new unless defined?(::RubyLLM) && model
|
|
74
|
+
|
|
75
|
+
model_info = ::RubyLLM.models.find(model)
|
|
76
|
+
tokens = ::RubyLLM::Tokens.new(
|
|
77
|
+
input: usage.input_tokens,
|
|
78
|
+
output: usage.output_tokens,
|
|
79
|
+
cached: usage.cached_tokens,
|
|
80
|
+
cache_creation: usage.cache_write_tokens,
|
|
81
|
+
thinking: usage.thinking_tokens
|
|
82
|
+
)
|
|
83
|
+
from_hash(::RubyLLM::Cost.new(tokens: tokens, model: model_info).to_h)
|
|
84
|
+
rescue ::RubyLLM::ModelNotFoundError
|
|
87
85
|
new
|
|
88
86
|
end
|
|
89
87
|
|
|
@@ -92,9 +90,9 @@ module TurnKit
|
|
|
92
90
|
new(
|
|
93
91
|
input: hash[:input],
|
|
94
92
|
output: hash[:output],
|
|
95
|
-
cache_read: hash[:cache_read]
|
|
96
|
-
cache_write: hash[:cache_write]
|
|
97
|
-
thinking: hash[:thinking]
|
|
93
|
+
cache_read: hash[:cache_read],
|
|
94
|
+
cache_write: hash[:cache_write],
|
|
95
|
+
thinking: hash[:thinking],
|
|
98
96
|
total: hash[:total]
|
|
99
97
|
)
|
|
100
98
|
end
|
data/lib/turnkit/image_result.rb
CHANGED
data/lib/turnkit/image_tool.rb
CHANGED
data/lib/turnkit/memory_store.rb
CHANGED
|
@@ -100,10 +100,12 @@ module TurnKit
|
|
|
100
100
|
@mutex.synchronize { duplicate(@tool_executions.fetch(id)) }
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
def
|
|
104
|
-
attrs = Record.tool_execution_update(attributes)
|
|
103
|
+
def claim_tool_execution(id, from: "running", to: "completed", **attributes)
|
|
104
|
+
attrs = Record.tool_execution_update(attributes.merge(status: to))
|
|
105
105
|
@mutex.synchronize do
|
|
106
106
|
record = @tool_executions.fetch(id)
|
|
107
|
+
return nil unless record["status"] == from
|
|
108
|
+
|
|
107
109
|
record.merge!(attrs.merge("updated_at" => Clock.now))
|
|
108
110
|
duplicate(record)
|
|
109
111
|
end
|
|
@@ -118,11 +120,14 @@ module TurnKit
|
|
|
118
120
|
end
|
|
119
121
|
end
|
|
120
122
|
|
|
121
|
-
def
|
|
123
|
+
def reconcile_stale_turns(before:)
|
|
122
124
|
@mutex.synchronize do
|
|
123
|
-
@turns.values.
|
|
124
|
-
%w[pending running].include?(turn["status"]) && stale_anchor(turn) && stale_anchor(turn) < before
|
|
125
|
-
|
|
125
|
+
@turns.values.filter_map do |turn|
|
|
126
|
+
next unless %w[pending running].include?(turn["status"]) && stale_anchor(turn) && stale_anchor(turn) < before
|
|
127
|
+
|
|
128
|
+
turn.merge!("status" => "stale", "completed_at" => Clock.now, "updated_at" => Clock.now)
|
|
129
|
+
duplicate(turn)
|
|
130
|
+
end
|
|
126
131
|
end
|
|
127
132
|
end
|
|
128
133
|
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class ModelRequest
|
|
5
|
-
attr_reader :model, :messages, :tools, :instructions, :thinking, :output_schema, :metadata, :report
|
|
5
|
+
attr_reader :model, :messages, :tools, :instructions, :dynamic_instructions, :thinking, :output_schema, :metadata, :report
|
|
6
6
|
|
|
7
|
-
def initialize(model:, messages:, tools:, instructions:, thinking: nil, output_schema: nil, metadata: {}, report: nil)
|
|
7
|
+
def initialize(model:, messages:, tools:, instructions:, dynamic_instructions: nil, thinking: nil, output_schema: nil, metadata: {}, report: nil)
|
|
8
8
|
@model = model
|
|
9
9
|
@messages = Array(messages)
|
|
10
10
|
@tools = Array(tools)
|
|
11
11
|
@instructions = instructions.to_s
|
|
12
|
+
@dynamic_instructions = dynamic_instructions.to_s
|
|
12
13
|
@thinking = thinking
|
|
13
14
|
@output_schema = output_schema
|
|
14
15
|
@metadata = metadata || {}
|
|
@@ -25,6 +26,7 @@ module TurnKit
|
|
|
25
26
|
"messages" => messages,
|
|
26
27
|
"tools" => tool_names,
|
|
27
28
|
"instructions" => instructions,
|
|
29
|
+
"dynamic_instructions" => dynamic_instructions,
|
|
28
30
|
"thinking" => thinking,
|
|
29
31
|
"output_schema" => output_schema,
|
|
30
32
|
"metadata" => metadata,
|