turnkit 0.4.1 → 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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/README.md +111 -50
  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 +30 -46
  10. data/lib/turnkit/agent.rb +43 -10
  11. data/lib/turnkit/budget.rb +1 -1
  12. data/lib/turnkit/client.rb +5 -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 +0 -2
  18. data/lib/turnkit/model_request.rb +4 -2
  19. data/lib/turnkit/output_policy.rb +1 -15
  20. data/lib/turnkit/run.rb +0 -4
  21. data/lib/turnkit/store.rb +4 -6
  22. data/lib/turnkit/sub_agent_tool.rb +9 -14
  23. data/lib/turnkit/system_prompt.rb +32 -96
  24. data/lib/turnkit/tool.rb +5 -16
  25. data/lib/turnkit/turn.rb +73 -89
  26. data/lib/turnkit/version.rb +1 -1
  27. data/lib/turnkit/view_media_tool.rb +2 -2
  28. data/lib/turnkit.rb +1 -18
  29. metadata +15 -30
  30. data/lib/turnkit/prompt_contribution.rb +0 -13
  31. data/lib/turnkit/rails/railtie.rb +0 -9
  32. data/lib/turnkit/workflow.rb +0 -58
  33. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/conversation.rb +0 -0
  34. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/create_turnkit_tables.rb +0 -0
  35. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/message.rb +0 -0
  36. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/tool_execution.rb +0 -0
  37. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/turn.rb +0 -0
  38. /data/lib/{turnkit/generators → generators}/turnkit/install_generator.rb +0 -0
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,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
 
@@ -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,
@@ -24,8 +24,6 @@ module TurnKit
24
24
  !data.nil?
25
25
  end
26
26
 
27
- alias structured? data?
28
-
29
27
  def cost
30
28
  Cost.from_usage(usage, model: model)
31
29
  end
@@ -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,
@@ -75,7 +75,7 @@ module TurnKit
75
75
  else
76
76
  audit_client = client || TurnKit.client
77
77
  audit_client.validate!(model: model_name)
78
- chat(audit_client, model: model_name, messages: audit_messages(output), tools: [], instructions: audit_instructions, thinking: thinking, output_schema: DEFAULT_SCHEMA, metadata: { output_policy: name })
78
+ audit_client.chat(model: model_name, messages: audit_messages(output), tools: [], instructions: audit_instructions, thinking: thinking, output_schema: DEFAULT_SCHEMA, metadata: { output_policy: name })
79
79
  end
80
80
  data = result.output_data || parse_json(result.text)
81
81
  return if data.fetch("approved", false)
@@ -111,20 +111,6 @@ module TurnKit
111
111
  [ { role: :user, content: JSON.generate(output: output) } ]
112
112
  end
113
113
 
114
- def chat(client, **kwargs)
115
- accepted = chat_keyword_names(client)
116
- kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
117
- client.chat(**kwargs)
118
- end
119
-
120
- def chat_keyword_names(client)
121
- client.method(:chat).parameters.filter_map do |kind, name|
122
- return [ :keyrest ] if kind == :keyrest
123
-
124
- name if %i[key keyreq].include?(kind)
125
- end
126
- end
127
-
128
114
  def parse_json(value)
129
115
  JSON.parse(extract_json(value.to_s))
130
116
  rescue JSON::ParserError
data/lib/turnkit/run.rb CHANGED
@@ -11,16 +11,12 @@ module TurnKit
11
11
  def id = turn.id
12
12
  def root_turn_id = turn.root_turn_id
13
13
  def status = turn.status
14
- def output = output_text
15
14
  def output_text = turn.output_text
16
15
  def output_data = turn.output_data
17
16
  def policy_audit = turn.policy_audit
18
17
  def policy_clean? = policy_audit.nil? || policy_audit.fetch("clean", false)
19
18
  def usage = Usage.from_records(turn_records)
20
19
  def cost = Cost.from_records(turn_records)
21
- def steps = turn_records.length
22
- def tool_calls = tool_executions
23
- def persisted? = true
24
20
 
25
21
  def error
26
22
  turn.store.load_turn(id)["error"]
data/lib/turnkit/store.rb CHANGED
@@ -12,12 +12,10 @@ module TurnKit
12
12
  def create_turn(_attributes) = raise(NotImplementedError)
13
13
  def load_turn(_id) = raise(NotImplementedError)
14
14
  def update_turn(_id, _attributes) = raise(NotImplementedError)
15
- def claim_turn(id, from: "pending", to: "running", **attributes)
16
- turn = load_turn(id)
17
- return nil unless turn["status"] == from
18
-
19
- update_turn(id, attributes.merge(status: to))
20
- end
15
+ # claim_turn is the concurrency-safety point: it must atomically
16
+ # compare-and-set status from `from` to `to` (returning nil when the turn
17
+ # is not in `from`), so concurrent workers cannot both claim a turn.
18
+ def claim_turn(_id, from: "pending", to: "running", **_attributes) = raise(NotImplementedError)
21
19
  def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil) = raise(NotImplementedError)
22
20
 
23
21
  def create_tool_execution(_attributes) = raise(NotImplementedError)
@@ -2,8 +2,7 @@
2
2
 
3
3
  module TurnKit
4
4
  class SubAgentTool < Tool
5
- parameter :task, :string, required: true, description: "The task for the sub-agent to complete."
6
- parameter :context, :string, required: false, description: "Relevant context for the sub-agent."
5
+ parameter :task, :string, required: true, description: "The complete task for the sub-agent, including all relevant context."
7
6
 
8
7
  def self.for(agent)
9
8
  Class.new(self) do
@@ -18,25 +17,21 @@ module TurnKit
18
17
  end
19
18
  end
20
19
 
21
- def call(task:, context: nil, turnkit_context:)
20
+ def call(task:, context:)
22
21
  sub_agent = self.class.agent
23
- parent_turn = turnkit_context.turn
24
- prompt = [ task, context ].compact.join("\n\n")
25
- conversation = sub_agent.conversation(metadata: {
22
+ parent_turn = context.turn
23
+ lineage = {
26
24
  "parent_conversation_id" => parent_turn.conversation.id,
27
25
  "parent_turn_id" => parent_turn.id,
28
- "parent_tool_execution_id" => turnkit_context.execution.id
29
- })
30
- trigger = conversation.say(prompt, metadata: {
31
- "parent_conversation_id" => parent_turn.conversation.id,
32
- "parent_turn_id" => parent_turn.id,
33
- "parent_tool_execution_id" => turnkit_context.execution.id
34
- })
26
+ "parent_tool_execution_id" => context.execution.id
27
+ }
28
+ conversation = sub_agent.conversation(metadata: lineage)
29
+ trigger = conversation.say(task, metadata: lineage)
35
30
  child = conversation.run!(
36
31
  trigger_message_id: trigger.id,
37
32
  budget: parent_turn.budget,
38
33
  parent_turn: parent_turn,
39
- parent_tool_execution: turnkit_context.execution,
34
+ parent_tool_execution: context.execution,
40
35
  depth: parent_turn.depth + 1,
41
36
  model: sub_agent.effective_model,
42
37
  agent: sub_agent,