turnkit 0.4.0 → 0.4.2

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +170 -49
  4. data/UPGRADE.md +29 -0
  5. data/UPGRADE_TO_0_4_2.md +425 -0
  6. data/lib/{turnkit/generators → generators}/turnkit/install/templates/initializer.rb +7 -6
  7. data/lib/turnkit/{stores/active_record_store.rb → active_record_store.rb} +18 -4
  8. data/lib/turnkit/adapters/codex.rb +8 -11
  9. data/lib/turnkit/adapters/ruby_llm.rb +68 -44
  10. data/lib/turnkit/agent.rb +43 -10
  11. data/lib/turnkit/budget.rb +1 -1
  12. data/lib/turnkit/client.rb +9 -1
  13. data/lib/turnkit/compaction.rb +46 -46
  14. data/lib/turnkit/cost.rb +29 -31
  15. data/lib/turnkit/image_result.rb +1 -0
  16. data/lib/turnkit/image_tool.rb +2 -2
  17. data/lib/turnkit/media_analysis_result.rb +46 -0
  18. data/lib/turnkit/media_input.rb +208 -0
  19. data/lib/turnkit/message.rb +5 -1
  20. data/lib/turnkit/message_projection.rb +11 -0
  21. data/lib/turnkit/model_request.rb +4 -2
  22. data/lib/turnkit/output_policy.rb +10 -15
  23. data/lib/turnkit/result.rb +12 -0
  24. data/lib/turnkit/run.rb +0 -4
  25. data/lib/turnkit/store.rb +4 -6
  26. data/lib/turnkit/sub_agent_tool.rb +9 -14
  27. data/lib/turnkit/system_prompt.rb +32 -96
  28. data/lib/turnkit/tool.rb +5 -16
  29. data/lib/turnkit/turn.rb +113 -58
  30. data/lib/turnkit/version.rb +1 -1
  31. data/lib/turnkit/view_media_tool.rb +30 -0
  32. data/lib/turnkit.rb +9 -18
  33. metadata +18 -30
  34. data/lib/turnkit/prompt_contribution.rb +0 -13
  35. data/lib/turnkit/rails/railtie.rb +0 -9
  36. data/lib/turnkit/workflow.rb +0 -58
  37. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/conversation.rb +0 -0
  38. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/create_turnkit_tables.rb +0 -0
  39. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/message.rb +0 -0
  40. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/tool_execution.rb +0 -0
  41. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/turn.rb +0 -0
  42. /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
- require "ruby_llm"
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
- require "ruby_llm"
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
- require "ruby_llm"
46
-
43
+ ensure_ruby_llm!
47
44
  configure_from_environment
48
- kwargs = paint_kwargs(
45
+ image = ::RubyLLM.paint(
46
+ prompt,
49
47
  model: model,
50
48
  provider: provider,
51
49
  assume_model_exists: assume_model_exists || false,
@@ -54,11 +52,32 @@ 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
 
58
+ def view_media(media:, objective:, model:, provider: nil, output_schema: nil, params: {}, metadata: nil, on_event: nil)
59
+ ensure_ruby_llm!
60
+ configure_from_environment
61
+ media_input = MediaInput.wrap(media)
62
+ content = ::RubyLLM::Content.new(objective.to_s)
63
+ content.add_attachment(media_input.attachment_source, filename: media_input.filename)
64
+
65
+ chat = ::RubyLLM.chat(model: model)
66
+ chat.with_schema(normalize_schema(output_schema)) if output_schema
67
+ chat.with_params(**params) if params && !params.empty?
68
+ chat.add_message(role: :user, content: content)
69
+
70
+ response = complete_without_tool_execution(chat)
71
+ normalize_media_analysis_response(response, media: media_input, model: model, provider: provider, params: params || {}, metadata: metadata)
72
+ end
73
+
61
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
+
62
81
  def configure_from_environment
63
82
  config = ::RubyLLM.config
64
83
  config.openai_api_key ||= ENV["OPENAI_API_KEY"]
@@ -100,19 +119,17 @@ module TurnKit
100
119
  end
101
120
  end
102
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.
103
127
  def complete_without_tool_execution(chat)
104
- provider = chat.instance_variable_get(:@provider)
105
- provider.complete(
106
- chat.messages,
107
- tools: chat.tools,
108
- tool_prefs: chat.tool_prefs,
109
- temperature: chat.instance_variable_get(:@temperature),
110
- model: chat.model,
111
- params: chat.params,
112
- headers: chat.headers,
113
- schema: chat.schema,
114
- thinking: chat.instance_variable_get(:@thinking)
115
- )
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)
116
133
  end
117
134
 
118
135
  def add_message(chat, message)
@@ -128,15 +145,16 @@ module TurnKit
128
145
  )
129
146
  end
130
147
 
131
- def add_instructions(chat, instructions, model:)
132
- return if instructions.nil? || instructions.empty?
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?
133
152
 
134
- if prompt_cache_enabled? && anthropic_model?(model) && instructions.include?(SystemPrompt::CACHE_BOUNDARY)
135
- stable, dynamic = SystemPrompt.split_cache_boundary(instructions)
153
+ if prompt_cache_enabled? && anthropic_model?(model) && !dynamic.empty?
136
154
  add_system_message(chat, stable, cache: true)
137
155
  add_system_message(chat, dynamic, cache: false)
138
156
  else
139
- chat.with_instructions(instructions)
157
+ chat.with_instructions([ stable, dynamic ].reject(&:empty?).join("\n\n"))
140
158
  end
141
159
  end
142
160
 
@@ -172,8 +190,6 @@ module TurnKit
172
190
  end
173
191
 
174
192
  def ruby_llm_tool(tool)
175
- require "ruby_llm"
176
-
177
193
  Class.new(::RubyLLM::Tool) do
178
194
  define_singleton_method(:name) { tool.tool_name }
179
195
  description tool.description
@@ -264,21 +280,6 @@ module TurnKit
264
280
  response.cost&.total
265
281
  end
266
282
 
267
- def paint_kwargs(kwargs)
268
- parameters = ::RubyLLM::Image.method(:paint).parameters
269
- return kwargs if parameters.any? { |kind, _| kind == :keyrest }
270
-
271
- accepted = parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) }
272
- unsupported = kwargs.keys.select { |key| !accepted.include?(key) && !blank?(kwargs[key]) }
273
- raise ArgumentError, "RubyLLM image generation does not support: #{unsupported.join(", ")}" if unsupported.any?
274
-
275
- kwargs.slice(*accepted)
276
- end
277
-
278
- def blank?(value)
279
- value.nil? || value == false || (value.respond_to?(:empty?) && value.empty?)
280
- end
281
-
282
283
  def normalize_image_response(image, model:, provider:, params:, metadata:)
283
284
  usage = Usage.new(
284
285
  input_tokens: image_usage_value(image, "input_tokens"),
@@ -300,6 +301,29 @@ module TurnKit
300
301
  Result.new(parts: [ part ], usage: usage, model: part["model"], output_data: { "type" => "image", "images" => [ part ] })
301
302
  end
302
303
 
304
+ def normalize_media_analysis_response(response, media:, model:, provider:, params:, metadata:)
305
+ usage = Usage.new(
306
+ input_tokens: token_value(response, :input_tokens),
307
+ output_tokens: token_value(response, :output_tokens),
308
+ cached_tokens: token_value(response, :cached_tokens),
309
+ cache_write_tokens: token_value(response, :cache_creation_tokens),
310
+ thinking_tokens: thinking_token_value(response),
311
+ cost: response_cost(response)
312
+ )
313
+ part = MediaAnalysisResult.new(
314
+ text: response_text(response),
315
+ data: response_data(response),
316
+ model: response.respond_to?(:model_id) ? response.model_id : model,
317
+ provider: provider&.to_s,
318
+ usage: usage,
319
+ params: params,
320
+ media: media.to_h,
321
+ metadata: metadata || {}
322
+ ).to_h.merge("type" => "media_analysis")
323
+
324
+ Result.new(parts: [ part ], usage: usage, model: part["model"], output_data: { "type" => "media_analysis", "media_analyses" => [ part ] })
325
+ end
326
+
303
327
  def image_usage_value(image, key)
304
328
  usage = image.respond_to?(:usage) ? image.usage || {} : {}
305
329
  (usage[key] || usage[key.to_sym]).to_i
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
- @instructions = instructions.to_s
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 run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, prompt_mode: :task, **options)
73
- task = task || prompt
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 build_budget(root_started_at: Clock.now)
155
- Budget.new(
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
- root_started_at: root_started_at
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
@@ -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["options"] || {})["iterations"].to_i }
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 }
@@ -1,17 +1,25 @@
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
 
13
17
  def paint(prompt:, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: nil, on_event: nil)
14
18
  raise NotImplementedError
15
19
  end
20
+
21
+ def view_media(media:, objective:, model:, provider: nil, output_schema: nil, params: {}, metadata: nil, on_event: nil)
22
+ raise NotImplementedError
23
+ end
16
24
  end
17
25
  end
@@ -3,19 +3,19 @@
3
3
  module TurnKit
4
4
  module Compaction
5
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
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)["enabled"]
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("enabled" => false) if global == false
104
- return DEFAULTS.merge("enabled" => false) if local == false
105
- return DEFAULTS.merge("enabled" => false) if override == false
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["enabled"]
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["enabled"]
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("middle"))
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("middle"),
145
- previous_summary: selected["previous_summary"]&.text,
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["summary_ratio"].to_f).ceil
213
- budget = [ budget, policy["min_summary_tokens"].to_i ].max
214
- [ budget, policy["max_summary_tokens"].to_i ].min
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["context_limit"].to_i - policy["reserved_tokens"].to_i, 1 ].max
219
- estimate_messages_tokens(messages) >= (usable * policy["threshold"].to_f)
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["head_messages"].to_i + 1
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["head_messages"].to_i + 1
228
+ return nil if candidates.length <= policy[:head_messages].to_i + 1
229
229
 
230
- head_count = policy["head_messages"].to_i
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
- "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
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(&:to_s)
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["tail_messages"].to_i
327
- max_tokens = policy["tail_tokens"].to_i
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["client"] || agent.effective_client
361
- model = policy["model"] || fallback_model
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["client"])
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["tool_output_max_chars"].to_i
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["model"] || turn&.model || conversation.model || conversation.agent.effective_model
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("replaces_from_sequence"),
406
- "replaces_through_sequence" => selected.fetch("replaces_through_sequence"),
407
- "tail_start_sequence" => selected["tail_start_sequence"],
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] || rates[:input_per_million]),
55
- output: amount(usage.output_tokens, rates[:output] || rates[:output_per_million]),
56
- cache_read: amount(usage.cached_tokens, rates[:cache_read] || rates[:cached_input] || rates[:cache_read_input_per_million] || rates[:cached_input_per_million]),
57
- cache_write: amount(usage.cache_write_tokens, rates[:cache_write] || rates[:cache_creation] || rates[:cache_write_input_per_million] || rates[:cache_creation_input_per_million]),
58
- thinking: amount(usage.thinking_tokens, rates[:thinking] || rates[:reasoning] || rates[:thinking_output] || rates[:reasoning_output] || rates[:thinking_output_per_million] || rates[:reasoning_output_per_million]),
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
- require "ruby_llm"
65
-
66
- model_info = ::RubyLLM.models.find(model) if model
67
- return new unless model_info
68
-
69
- if defined?(::RubyLLM::Cost)
70
- tokens = ::RubyLLM::Tokens.new(
71
- input: usage.input_tokens,
72
- output: usage.output_tokens,
73
- cached: usage.cached_tokens,
74
- cache_creation: usage.cache_write_tokens,
75
- thinking: usage.thinking_tokens
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] || hash[:cached_input],
96
- cache_write: hash[:cache_write] || hash[:cache_creation],
97
- thinking: hash[:thinking] || hash[:reasoning] || hash[:thinking_output] || hash[:reasoning_output],
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
@@ -23,6 +23,7 @@ module TurnKit
23
23
  @metadata = metadata || {}
24
24
  end
25
25
 
26
+ # Performs network IO when the image is URL-backed.
26
27
  def to_blob
27
28
  raise Error, "image has no url or data" if url.to_s.empty? && data.to_s.empty?
28
29
 
@@ -11,8 +11,8 @@ module TurnKit
11
11
  end
12
12
  end
13
13
 
14
- def call(turnkit_context:, **arguments)
15
- turnkit_context.turn.paint(
14
+ def call(context:, **arguments)
15
+ context.turn.paint(
16
16
  prompt(**arguments),
17
17
  model: self.class.model,
18
18
  provider: self.class.provider,