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
@@ -3,7 +3,6 @@
3
3
  module TurnKit
4
4
  class SystemPrompt
5
5
  DEFAULT_SECTIONS = %i[agent instructions behavior loaded_skills available_skills tools subject live_context environment].freeze
6
- CACHE_BOUNDARY = "<!-- TURNKIT_DYNAMIC_PROMPT_BOUNDARY -->"
7
6
  NONE_PROMPT = "You are an assistant running inside TurnKit."
8
7
  PROMPT_MODES = %i[full minimal task none].freeze
9
8
  MODE_SECTIONS = {
@@ -13,7 +12,6 @@ module TurnKit
13
12
  none: []
14
13
  }.freeze
15
14
  DYNAMIC_SECTIONS = %i[subject live_context environment].freeze
16
- OVERRIDABLE_SECTIONS = %i[behavior tools].freeze
17
15
 
18
16
  SECTION_METHODS = {
19
17
  agent: :agent_section,
@@ -92,44 +90,28 @@ module TurnKit
92
90
  raise ArgumentError, "unknown prompt mode: #{@mode}" unless PROMPT_MODES.include?(@mode)
93
91
 
94
92
  @sections = Array(sections || prompt_sections_for_mode)
95
- @prompt_contribution = nil
96
93
  end
97
94
 
98
- def to_s
99
- return NONE_PROMPT if mode == :none
100
-
101
- values = []
102
- contribution = prompt_contribution
103
- values << contribution.stable_prefix unless contribution.stable_prefix.empty?
104
-
105
- boundary_inserted = false
106
- sections.each do |section|
107
- rendered = render(section)
108
- next if rendered.nil? || rendered.strip.empty?
109
-
110
- if dynamic_section?(section) && !boundary_inserted
111
- values << CACHE_BOUNDARY
112
- boundary_inserted = true
113
- end
114
-
115
- values << rendered
116
- end
95
+ # The prompt splits into a stable part (identical across turns of the same
96
+ # agent, safe to cache) and a dynamic part (subject, live context,
97
+ # environment) recomputed each turn. Adapters that support prompt caching
98
+ # receive both via ModelRequest#instructions and #dynamic_instructions.
99
+ def stable
100
+ parts.fetch(0).join("\n\n")
101
+ end
117
102
 
118
- unless contribution.dynamic_suffix.empty?
119
- values << CACHE_BOUNDARY unless boundary_inserted
120
- values << contribution.dynamic_suffix
121
- end
103
+ def dynamic
104
+ parts.fetch(1).join("\n\n")
105
+ end
122
106
 
123
- values.compact.reject { |value| value.strip.empty? }.join("\n\n")
107
+ def to_s
108
+ [ stable, dynamic ].reject(&:empty?).join("\n\n")
124
109
  end
125
110
 
126
111
  def render(section)
127
112
  method = SECTION_METHODS[section.to_sym]
128
113
  raise ArgumentError, "unknown prompt section: #{section}" unless method
129
114
 
130
- override = section_override(section)
131
- return tagged(section, override) if override
132
-
133
115
  public_send(method)
134
116
  end
135
117
 
@@ -292,11 +274,9 @@ module TurnKit
292
274
 
293
275
  def report
294
276
  text = to_s
295
- stable, dynamic = self.class.split_cache_boundary(text)
296
277
  {
297
278
  "chars" => text.length,
298
279
  "hash" => Digest::SHA256.hexdigest(text),
299
- "has_cache_boundary" => text.include?(CACHE_BOUNDARY),
300
280
  "stable_chars" => stable.length,
301
281
  "dynamic_chars" => dynamic.length,
302
282
  "sections" => sections.map(&:to_s),
@@ -304,12 +284,27 @@ module TurnKit
304
284
  }
305
285
  end
306
286
 
307
- def self.split_cache_boundary(text)
308
- stable, dynamic = text.to_s.split(CACHE_BOUNDARY, 2)
309
- [ stable.to_s, dynamic.to_s ]
310
- end
311
-
312
287
  private
288
+ def parts
289
+ @parts ||= build_parts
290
+ end
291
+
292
+ def build_parts
293
+ return [ [ NONE_PROMPT ], [] ] if mode == :none
294
+
295
+ stable_parts = []
296
+ dynamic_parts = []
297
+ target = stable_parts
298
+ sections.each do |section|
299
+ rendered = render(section)
300
+ next if rendered.nil? || rendered.strip.empty?
301
+
302
+ target = dynamic_parts if dynamic_section?(section)
303
+ target << rendered
304
+ end
305
+ [ stable_parts, dynamic_parts ]
306
+ end
307
+
313
308
  def tagged(name, content)
314
309
  "<#{name}>\n#{content}\n</#{name}>"
315
310
  end
@@ -383,64 +378,5 @@ module TurnKit
383
378
  end
384
379
  end
385
380
 
386
- def prompt_contribution
387
- @prompt_contribution ||= merge_prompt_contributions(resolve_prompt_contributions)
388
- end
389
-
390
- def resolve_prompt_contributions
391
- contributors = Array(TurnKit.system_prompt_contributors)
392
- contributors += matching_model_prompt_contributors
393
- contributors.filter_map do |contributor|
394
- value = contributor.respond_to?(:call) ? contributor.call(prompt_build_context) : contributor
395
- normalize_prompt_contribution(value)
396
- end
397
- end
398
-
399
- def matching_model_prompt_contributors
400
- model_name = (turn.model || agent.effective_model).to_s
401
- TurnKit.model_prompt_contributors.flat_map do |matcher, contributor|
402
- matches = case matcher
403
- when Regexp
404
- matcher.match?(model_name)
405
- else
406
- matcher.to_s == model_name
407
- end
408
- matches ? Array(contributor) : []
409
- end
410
- end
411
-
412
- def normalize_prompt_contribution(value)
413
- case value
414
- when nil, false
415
- nil
416
- when PromptContribution
417
- value
418
- when Hash
419
- PromptContribution.new(
420
- stable_prefix: value[:stable_prefix] || value["stable_prefix"],
421
- dynamic_suffix: value[:dynamic_suffix] || value["dynamic_suffix"],
422
- section_overrides: value[:section_overrides] || value["section_overrides"]
423
- )
424
- else
425
- PromptContribution.new(stable_prefix: value.to_s)
426
- end
427
- end
428
-
429
- def merge_prompt_contributions(contributions)
430
- stable_prefix = contributions.map(&:stable_prefix).reject(&:empty?).join("\n\n")
431
- dynamic_suffix = contributions.map(&:dynamic_suffix).reject(&:empty?).join("\n\n")
432
- section_overrides = contributions.each_with_object({}) do |contribution, overrides|
433
- overrides.merge!(contribution.section_overrides)
434
- end
435
- PromptContribution.new(stable_prefix: stable_prefix, dynamic_suffix: dynamic_suffix, section_overrides: section_overrides)
436
- end
437
-
438
- def section_override(section)
439
- key = section.to_sym
440
- return nil unless OVERRIDABLE_SECTIONS.include?(key)
441
-
442
- value = prompt_contribution.section_overrides[key]
443
- value.to_s unless value.nil?
444
- end
445
381
  end
446
382
  end
data/lib/turnkit/tool.rb CHANGED
@@ -25,6 +25,7 @@ module TurnKit
25
25
  name = name.to_s
26
26
  raise ArgumentError, "unknown parameter type: #{type}" unless TYPES.include?(type)
27
27
  raise ArgumentError, "invalid parameter name: #{name}" unless NAME_PATTERN.match?(name)
28
+ raise ArgumentError, "context is a reserved parameter name" if name == "context"
28
29
  raise ArgumentError, "duplicate parameter: #{name}" if parameters.any? { |param| param.fetch(:name) == name }
29
30
  raise ArgumentError, "enum values are required for enum parameter: #{name}" if type == :enum && Array(enum).empty?
30
31
 
@@ -113,23 +114,15 @@ module TurnKit
113
114
  end
114
115
 
115
116
  def call(arguments = {}, context:)
116
- instance = begin
117
- new
118
- rescue ArgumentError => error
119
- raise if error.message !~ /wrong number of arguments|missing keyword/
117
+ required = instance_method(:initialize).parameters.any? { |kind, _| %i[req keyreq].include?(kind) }
118
+ raise ToolError, "#{tool_name} requires constructor arguments; register an instance instead" if required
120
119
 
121
- raise ToolError, "#{tool_name} requires constructor arguments; register an instance instead"
122
- end
123
- invoke(instance, arguments, context: context)
120
+ invoke(new, arguments, context: context)
124
121
  end
125
122
 
126
123
  def invoke(instance, arguments = {}, context:)
127
124
  keyword_arguments = symbolize(validate_arguments(arguments))
128
- if accepts_turnkit_context?(instance)
129
- instance.call(**keyword_arguments, turnkit_context: context)
130
- else
131
- instance.call(**keyword_arguments, context: context)
132
- end
125
+ instance.call(**keyword_arguments, context: context)
133
126
  end
134
127
 
135
128
  private
@@ -172,10 +165,6 @@ module TurnKit
172
165
  SchemaCheck.validate!(value, schema_for(param), error_class: ToolValidationError, label: param.fetch(:name))
173
166
  end
174
167
 
175
- def accepts_turnkit_context?(instance)
176
- instance.method(:call).parameters.any? { |kind, name| %i[key keyreq].include?(kind) && name == :turnkit_context }
177
- end
178
-
179
168
  def symbolize(hash)
180
169
  hash.transform_keys(&:to_sym)
181
170
  end
data/lib/turnkit/turn.rb CHANGED
@@ -43,7 +43,7 @@ module TurnKit
43
43
  @started_at = @record["started_at"]
44
44
  emit("turn.started", status: status, model: model)
45
45
  agent.effective_client.validate!(model: model)
46
- @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: budget_limits)
46
+ @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
47
47
  revisions_used = 0
48
48
  loop do
49
49
  budget.check!(depth: depth)
@@ -113,7 +113,15 @@ module TurnKit
113
113
  end
114
114
 
115
115
  def policy_audit
116
- (@record["options"] || {})["policy_audit"]
116
+ options = @record["options"] || {}
117
+ options.dig("state", "policy_audit") || options["policy_audit"]
118
+ end
119
+
120
+ # Reads iterations from options["state"], falling back to the legacy
121
+ # top-level key for turns persisted before the state split.
122
+ def self.iterations_for(record)
123
+ options = record["options"] || {}
124
+ (options.dig("state", "iterations") || options["iterations"]).to_i
117
125
  end
118
126
 
119
127
  def usage
@@ -168,23 +176,13 @@ module TurnKit
168
176
  end
169
177
 
170
178
  def paint(prompt, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: {}, client: nil)
171
- claimed_standalone = false
172
- case status
173
- when "pending"
174
- claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
175
- raise Error, "turn is already running" unless claimed
176
-
179
+ claimed = claim_standalone!("paint")
180
+ if claimed
177
181
  @record = claimed
178
182
  @started_at = @record["started_at"]
179
- @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: budget_limits)
180
- claimed_standalone = true
181
183
  emit("turn.started", status: status, model: model)
182
- when "running"
183
- # Image tools call this while their parent turn is running.
184
- else
185
- raise Error, "cannot paint for #{status} turn"
184
+ @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
186
185
  end
187
-
188
186
  image_client = client || agent.effective_client
189
187
  request = {
190
188
  prompt: prompt,
@@ -201,43 +199,28 @@ module TurnKit
201
199
  image_client.validate!(model: model)
202
200
  emit("image.requested", request.except(:input_images, :mask))
203
201
  result = call_image_client(image_client, request)
204
- result_cost = Cost.from_usage(result.usage, model: result.model || model)
205
- add_usage!(result.usage, cost: result_cost)
206
- budget.add_cost!(result_cost.total)
202
+ result_cost = apply_result_cost(result, model: model)
207
203
  image = result.images.first
208
204
  raise Error, "image client returned no image" unless image
209
205
  raise Error, "image client returned image without url or data" if image.url.to_s.empty? && image.data.to_s.empty?
210
206
 
211
207
  persist_image_message(image)
212
208
  emit("image.completed", image: image.to_h, model: image.model || model, provider: image.provider || provider&.to_s, mime_type: image.mime_type, usage: result.usage.to_h, cost: result_cost.to_h, metadata: metadata || {})
213
- complete_with_output(image.url.to_s, output_data: { "type" => "image", "images" => [ image.to_h ] }, audit: check_policy(image.url.to_s, output_data: { "type" => "image", "images" => [ image.to_h ] })) if claimed_standalone
209
+ complete_with_output(image.url.to_s, output_data: { "type" => "image", "images" => [ image.to_h ] }, audit: check_policy(image.url.to_s, output_data: { "type" => "image", "images" => [ image.to_h ] })) if claimed
214
210
  image
215
211
  rescue StandardError => error
216
- if claimed_standalone
217
- update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
218
- emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
219
- end
212
+ fail_standalone!(error) if claimed
220
213
  raise
221
214
  end
222
215
 
223
216
  def view_media(media, objective:, model:, provider: nil, output_schema: nil, params: {}, metadata: {}, client: nil)
224
- claimed_standalone = false
225
- case status
226
- when "pending"
227
- claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
228
- raise Error, "turn is already running" unless claimed
229
-
217
+ claimed = claim_standalone!("view media")
218
+ if claimed
230
219
  @record = claimed
231
220
  @started_at = @record["started_at"]
232
- @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: budget_limits)
233
- claimed_standalone = true
234
221
  emit("turn.started", status: status, model: model)
235
- when "running"
236
- # Media tools call this while their parent turn is running.
237
- else
238
- raise Error, "cannot view media for #{status} turn"
222
+ @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
239
223
  end
240
-
241
224
  media_input = MediaInput.wrap(media)
242
225
  media_client = client || agent.effective_client
243
226
  request = {
@@ -253,42 +236,38 @@ module TurnKit
253
236
  media_client.validate!(model: model)
254
237
  emit("media.requested", request.except(:media).merge(media: media_input.to_h))
255
238
  result = call_media_client(media_client, request)
256
- result_cost = Cost.from_usage(result.usage, model: result.model || model)
257
- add_usage!(result.usage, cost: result_cost)
258
- budget.add_cost!(result_cost.total)
239
+ result_cost = apply_result_cost(result, model: model)
259
240
  analysis = result.media_analyses.first
260
241
  raise Error, "media client returned no media analysis" unless analysis
261
242
 
262
243
  persist_media_analysis_message(analysis)
263
244
  output_data = { "type" => "media_analysis", "media_analyses" => [ analysis.to_h ] }
264
245
  emit("media.completed", analysis: analysis.to_h, model: analysis.model || model, provider: analysis.provider || provider&.to_s, media: media_input.to_h, usage: result.usage.to_h, cost: result_cost.to_h, metadata: metadata || {})
265
- complete_with_output(analysis.text, output_data: output_data, audit: check_policy(analysis.text, output_data: output_data)) if claimed_standalone
246
+ complete_with_output(analysis.text, output_data: output_data, audit: check_policy(analysis.text, output_data: output_data)) if claimed
266
247
  analysis
267
248
  rescue StandardError => error
268
- emit("media.failed", error: { "class" => error.class.name, "message" => error.message }, metadata: metadata || {}) if status == "running" || claimed_standalone
269
- if claimed_standalone
270
- update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
271
- emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
272
- end
249
+ emit("media.failed", error: { "class" => error.class.name, "message" => error.message }, metadata: metadata || {}) if status == "running" || claimed
250
+ fail_standalone!(error) if claimed
273
251
  raise
274
252
  end
275
253
 
276
254
  private
277
255
  def model_request
278
256
  prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
279
- instructions = case agent.system_prompt
257
+ instructions, dynamic_instructions = case agent.system_prompt
280
258
  when nil
281
- prompt.to_s
259
+ [ prompt.stable, prompt.dynamic ]
282
260
  when String
283
- agent.system_prompt
261
+ [ agent.system_prompt, nil ]
284
262
  else
285
- agent.system_prompt.call(prompt).to_s
263
+ [ agent.system_prompt.call(prompt).to_s, nil ]
286
264
  end
287
265
  ModelRequest.new(
288
266
  model: model,
289
267
  messages: llm_messages,
290
268
  tools: agent.effective_tools,
291
269
  instructions: instructions,
270
+ dynamic_instructions: dynamic_instructions,
292
271
  thinking: thinking,
293
272
  output_schema: output_schema,
294
273
  metadata: { turn_id: id, conversation_id: conversation.id },
@@ -296,48 +275,27 @@ module TurnKit
296
275
  )
297
276
  end
298
277
 
278
+ # Clients implement the TurnKit::Client keyword contract. See client.rb.
299
279
  def call_client(request, client: agent.effective_client)
300
- kwargs = {
280
+ client.chat(
301
281
  model: request.model,
302
282
  messages: request.messages,
303
283
  tools: request.tools,
304
284
  instructions: request.instructions,
285
+ dynamic_instructions: request.dynamic_instructions,
305
286
  thinking: request.thinking,
306
287
  output_schema: request.output_schema,
307
288
  metadata: request.metadata,
308
289
  on_event: ->(event) { emit_event(event) }
309
- }
310
- accepted = chat_keyword_names(client)
311
- kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
312
- client.chat(**kwargs)
313
- end
314
-
315
- def chat_keyword_names(client)
316
- client.method(:chat).parameters.filter_map do |kind, name|
317
- return [ :keyrest ] if kind == :keyrest
318
-
319
- name if %i[key keyreq].include?(kind)
320
- end
290
+ )
321
291
  end
322
292
 
323
293
  def call_image_client(client, request)
324
- kwargs = request.merge(on_event: ->(event) { emit_event(event) })
325
- accepted = client.method(:paint).parameters.filter_map do |kind, name|
326
- return client.paint(**kwargs) if kind == :keyrest
327
-
328
- name if %i[key keyreq].include?(kind)
329
- end
330
- client.paint(**kwargs.slice(*accepted))
294
+ client.paint(**request, on_event: ->(event) { emit_event(event) })
331
295
  end
332
296
 
333
297
  def call_media_client(client, request)
334
- kwargs = request.merge(on_event: ->(event) { emit_event(event) })
335
- accepted = client.method(:view_media).parameters.filter_map do |kind, name|
336
- return client.view_media(**kwargs) if kind == :keyrest
337
-
338
- name if %i[key keyreq].include?(kind)
339
- end
340
- client.view_media(**kwargs.slice(*accepted))
298
+ client.view_media(**request, on_event: ->(event) { emit_event(event) })
341
299
  end
342
300
 
343
301
  def llm_messages
@@ -453,8 +411,7 @@ module TurnKit
453
411
  end
454
412
 
455
413
  def persist_policy_audit(audit)
456
- options = (@record["options"] || {}).merge("policy_audit" => audit.to_h)
457
- update!(options: options)
414
+ update_state!("policy_audit" => audit.to_h)
458
415
  emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length)
459
416
  end
460
417
 
@@ -506,23 +463,50 @@ module TurnKit
506
463
 
507
464
  def count_iteration!
508
465
  budget.count_iteration!
509
- options = (@record["options"] || {}).merge("iterations" => (@record.dig("options", "iterations").to_i + 1))
510
- update!(options: options)
466
+ update_state!("iterations" => Turn.iterations_for(@record) + 1)
467
+ end
468
+
469
+ # Runtime state lives under options["state"]; the rest of options is
470
+ # write-once turn configuration. Reads fall back to the legacy top-level
471
+ # keys for turns persisted before the split.
472
+ def update_state!(changes)
473
+ options = @record["options"] || {}
474
+ update!(options: options.merge("state" => (options["state"] || {}).merge(changes)))
511
475
  end
512
476
 
513
477
  def heartbeat!
514
478
  update!(heartbeat_at: Clock.now)
515
479
  end
516
480
 
517
- def budget_limits
518
- {
519
- max_iterations: agent.max_iterations || TurnKit.max_iterations,
520
- timeout: agent.timeout || TurnKit.timeout,
521
- max_depth: agent.max_depth || TurnKit.max_depth,
522
- max_tool_executions: agent.max_tool_executions || TurnKit.max_tool_executions,
523
- max_tool_executions_by_name: agent.max_tool_executions_by_name || TurnKit.max_tool_executions_by_name,
524
- max_spend: agent.max_spend || TurnKit.max_spend
525
- }
481
+ # Claims a pending turn for a standalone media call. Returns the claimed
482
+ # record when this call owns turn completion, nil when running inside a parent
483
+ # turn (media tools call paint/view_media while their turn is running).
484
+ # Callers perform any post-claim setup after assignment, so later errors
485
+ # still fail the claimed turn.
486
+ def claim_standalone!(action)
487
+ case status
488
+ when "pending"
489
+ claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
490
+ raise Error, "turn is already running" unless claimed
491
+
492
+ claimed
493
+ when "running"
494
+ nil
495
+ else
496
+ raise Error, "cannot #{action} for #{status} turn"
497
+ end
498
+ end
499
+
500
+ def fail_standalone!(error)
501
+ update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
502
+ emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
503
+ end
504
+
505
+ def apply_result_cost(result, model:)
506
+ cost = Cost.from_usage(result.usage, model: result.model || model)
507
+ add_usage!(result.usage, cost: cost)
508
+ budget.add_cost!(cost.total)
509
+ cost
526
510
  end
527
511
 
528
512
  def aggregate_cost(current, cost)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.2"
5
5
  end
@@ -11,8 +11,8 @@ module TurnKit
11
11
  end
12
12
  end
13
13
 
14
- def call(turnkit_context:, **arguments)
15
- turnkit_context.turn.view_media(
14
+ def call(context:, **arguments)
15
+ context.turn.view_media(
16
16
  media(**arguments),
17
17
  objective: objective(**arguments),
18
18
  model: self.class.model,
data/lib/turnkit.rb CHANGED
@@ -17,7 +17,6 @@ require_relative "turnkit/event"
17
17
  require_relative "turnkit/model_request"
18
18
  require_relative "turnkit/schema_check"
19
19
  require_relative "turnkit/agent"
20
- require_relative "turnkit/workflow"
21
20
  require_relative "turnkit/client"
22
21
  require_relative "turnkit/conversation"
23
22
  require_relative "turnkit/message"
@@ -31,7 +30,6 @@ require_relative "turnkit/output_audit"
31
30
  require_relative "turnkit/output_policy"
32
31
  require_relative "turnkit/prompt_data"
33
32
  require_relative "turnkit/prompt_context"
34
- require_relative "turnkit/prompt_contribution"
35
33
  require_relative "turnkit/system_prompt"
36
34
  require_relative "turnkit/store"
37
35
  require_relative "turnkit/memory_store"
@@ -50,9 +48,7 @@ require_relative "turnkit/usage"
50
48
  require_relative "turnkit/run"
51
49
  require_relative "turnkit/adapters/codex"
52
50
  require_relative "turnkit/adapters/ruby_llm"
53
- require_relative "turnkit/stores/active_record_store"
54
-
55
- require_relative "turnkit/rails/railtie" if defined?(Rails)
51
+ require_relative "turnkit/active_record_store"
56
52
 
57
53
  module TurnKit
58
54
  class << self
@@ -65,10 +61,7 @@ module TurnKit
65
61
  attr_accessor :cost_rates, :cost_calculator
66
62
  attr_accessor :prompt_sections, :prompt_behavior, :available_skills
67
63
  attr_accessor :prompt_data_max_chars, :context_contributors
68
- attr_accessor :system_prompt_contributors, :model_prompt_contributors
69
64
  attr_accessor :on_event
70
- attr_accessor :conversation_record_class, :turn_record_class
71
- attr_accessor :message_record_class, :tool_execution_record_class
72
65
  end
73
66
 
74
67
  self.default_model = "claude-sonnet-4-5"
@@ -87,8 +80,6 @@ module TurnKit
87
80
  self.prompt_data_max_chars = 20_000
88
81
  self.available_skills = []
89
82
  self.context_contributors = []
90
- self.system_prompt_contributors = []
91
- self.model_prompt_contributors = {}
92
83
  self.on_event = nil
93
84
  self.output_policy_model = nil
94
85
  self.output_policy_thinking = { effort: :low }
@@ -97,14 +88,6 @@ module TurnKit
97
88
  yield self
98
89
  end
99
90
 
100
- def self.model
101
- default_model
102
- end
103
-
104
- def self.model=(value)
105
- self.default_model = value
106
- end
107
-
108
91
  def self.reconcile_stale!(before: Clock.now - (timeout || 300))
109
92
  store.find_stale_turns(before: before).each do |turn|
110
93
  store.update_turn(turn.fetch("id"), "status" => "stale", "completed_at" => Clock.now)