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
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,41 +199,75 @@ 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 })
212
+ fail_standalone!(error) if claimed
213
+ raise
214
+ end
215
+
216
+ def view_media(media, objective:, model:, provider: nil, output_schema: nil, params: {}, metadata: {}, client: nil)
217
+ claimed = claim_standalone!("view media")
218
+ if claimed
219
+ @record = claimed
220
+ @started_at = @record["started_at"]
221
+ emit("turn.started", status: status, model: model)
222
+ @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
219
223
  end
224
+ media_input = MediaInput.wrap(media)
225
+ media_client = client || agent.effective_client
226
+ request = {
227
+ media: media_input,
228
+ objective: objective,
229
+ model: model,
230
+ provider: provider,
231
+ output_schema: output_schema,
232
+ params: params || {},
233
+ metadata: { turn_id: id, conversation_id: conversation.id }.merge(metadata || {})
234
+ }
235
+
236
+ media_client.validate!(model: model)
237
+ emit("media.requested", request.except(:media).merge(media: media_input.to_h))
238
+ result = call_media_client(media_client, request)
239
+ result_cost = apply_result_cost(result, model: model)
240
+ analysis = result.media_analyses.first
241
+ raise Error, "media client returned no media analysis" unless analysis
242
+
243
+ persist_media_analysis_message(analysis)
244
+ output_data = { "type" => "media_analysis", "media_analyses" => [ analysis.to_h ] }
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 || {})
246
+ complete_with_output(analysis.text, output_data: output_data, audit: check_policy(analysis.text, output_data: output_data)) if claimed
247
+ analysis
248
+ rescue StandardError => error
249
+ emit("media.failed", error: { "class" => error.class.name, "message" => error.message }, metadata: metadata || {}) if status == "running" || claimed
250
+ fail_standalone!(error) if claimed
220
251
  raise
221
252
  end
222
253
 
223
254
  private
224
255
  def model_request
225
256
  prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
226
- instructions = case agent.system_prompt
257
+ instructions, dynamic_instructions = case agent.system_prompt
227
258
  when nil
228
- prompt.to_s
259
+ [ prompt.stable, prompt.dynamic ]
229
260
  when String
230
- agent.system_prompt
261
+ [ agent.system_prompt, nil ]
231
262
  else
232
- agent.system_prompt.call(prompt).to_s
263
+ [ agent.system_prompt.call(prompt).to_s, nil ]
233
264
  end
234
265
  ModelRequest.new(
235
266
  model: model,
236
267
  messages: llm_messages,
237
268
  tools: agent.effective_tools,
238
269
  instructions: instructions,
270
+ dynamic_instructions: dynamic_instructions,
239
271
  thinking: thinking,
240
272
  output_schema: output_schema,
241
273
  metadata: { turn_id: id, conversation_id: conversation.id },
@@ -243,38 +275,27 @@ module TurnKit
243
275
  )
244
276
  end
245
277
 
278
+ # Clients implement the TurnKit::Client keyword contract. See client.rb.
246
279
  def call_client(request, client: agent.effective_client)
247
- kwargs = {
280
+ client.chat(
248
281
  model: request.model,
249
282
  messages: request.messages,
250
283
  tools: request.tools,
251
284
  instructions: request.instructions,
285
+ dynamic_instructions: request.dynamic_instructions,
252
286
  thinking: request.thinking,
253
287
  output_schema: request.output_schema,
254
288
  metadata: request.metadata,
255
289
  on_event: ->(event) { emit_event(event) }
256
- }
257
- accepted = chat_keyword_names(client)
258
- kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
259
- client.chat(**kwargs)
260
- end
261
-
262
- def chat_keyword_names(client)
263
- client.method(:chat).parameters.filter_map do |kind, name|
264
- return [ :keyrest ] if kind == :keyrest
265
-
266
- name if %i[key keyreq].include?(kind)
267
- end
290
+ )
268
291
  end
269
292
 
270
293
  def call_image_client(client, request)
271
- kwargs = request.merge(on_event: ->(event) { emit_event(event) })
272
- accepted = client.method(:paint).parameters.filter_map do |kind, name|
273
- return client.paint(**kwargs) if kind == :keyrest
294
+ client.paint(**request, on_event: ->(event) { emit_event(event) })
295
+ end
274
296
 
275
- name if %i[key keyreq].include?(kind)
276
- end
277
- client.paint(**kwargs.slice(*accepted))
297
+ def call_media_client(client, request)
298
+ client.view_media(**request, on_event: ->(event) { emit_event(event) })
278
299
  end
279
300
 
280
301
  def llm_messages
@@ -337,6 +358,9 @@ module TurnKit
337
358
  elsif result.image?
338
359
  message = conversation.append_message(role: "assistant", kind: "image", content: result.images.map { |image| image.to_h.merge("type" => "image") }, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
339
360
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
361
+ elsif result.media_analysis?
362
+ message = conversation.append_message(role: "assistant", kind: "media_analysis", content: result.media_analyses.map { |analysis| analysis.to_h.merge("type" => "media_analysis") }, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
363
+ emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
340
364
  else
341
365
  message = conversation.append_message(role: "assistant", kind: "text", text: result.text, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
342
366
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
@@ -348,6 +372,11 @@ module TurnKit
348
372
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
349
373
  end
350
374
 
375
+ def persist_media_analysis_message(analysis)
376
+ message = conversation.append_message(role: "assistant", kind: "media_analysis", content: [ analysis.to_h.merge("type" => "media_analysis") ], turn_id: id, metadata: { "output_data" => { "type" => "media_analysis", "media_analyses" => [ analysis.to_h ] } })
377
+ emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
378
+ end
379
+
351
380
  def append_terminal_completion(runner, execution)
352
381
  message = runner.completion_message(execution)
353
382
  assistant = conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
@@ -382,8 +411,7 @@ module TurnKit
382
411
  end
383
412
 
384
413
  def persist_policy_audit(audit)
385
- options = (@record["options"] || {}).merge("policy_audit" => audit.to_h)
386
- update!(options: options)
414
+ update_state!("policy_audit" => audit.to_h)
387
415
  emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length)
388
416
  end
389
417
 
@@ -435,23 +463,50 @@ module TurnKit
435
463
 
436
464
  def count_iteration!
437
465
  budget.count_iteration!
438
- options = (@record["options"] || {}).merge("iterations" => (@record.dig("options", "iterations").to_i + 1))
439
- 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)))
440
475
  end
441
476
 
442
477
  def heartbeat!
443
478
  update!(heartbeat_at: Clock.now)
444
479
  end
445
480
 
446
- def budget_limits
447
- {
448
- max_iterations: agent.max_iterations || TurnKit.max_iterations,
449
- timeout: agent.timeout || TurnKit.timeout,
450
- max_depth: agent.max_depth || TurnKit.max_depth,
451
- max_tool_executions: agent.max_tool_executions || TurnKit.max_tool_executions,
452
- max_tool_executions_by_name: agent.max_tool_executions_by_name || TurnKit.max_tool_executions_by_name,
453
- max_spend: agent.max_spend || TurnKit.max_spend
454
- }
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
455
510
  end
456
511
 
457
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.0"
4
+ VERSION = "0.4.2"
5
5
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class ViewMediaTool < Tool
5
+ class << self
6
+ %i[model provider output_schema params].each do |name|
7
+ define_method(name) do |value = nil|
8
+ instance_variable_set("@#{name}", value) unless value.nil?
9
+ instance_variable_get("@#{name}")
10
+ end
11
+ end
12
+ end
13
+
14
+ def call(context:, **arguments)
15
+ context.turn.view_media(
16
+ media(**arguments),
17
+ objective: objective(**arguments),
18
+ model: self.class.model,
19
+ provider: self.class.provider,
20
+ output_schema: self.class.output_schema,
21
+ params: self.class.params || {},
22
+ metadata: metadata(**arguments)
23
+ ).to_h
24
+ end
25
+
26
+ def metadata(**)
27
+ {}
28
+ end
29
+ end
30
+ end
data/lib/turnkit.rb CHANGED
@@ -17,25 +17,26 @@ 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"
24
23
  require_relative "turnkit/record"
25
24
  require_relative "turnkit/image_result"
25
+ require_relative "turnkit/media_input"
26
+ require_relative "turnkit/media_analysis_result"
26
27
  require_relative "turnkit/result"
27
28
  require_relative "turnkit/skill"
28
29
  require_relative "turnkit/output_audit"
29
30
  require_relative "turnkit/output_policy"
30
31
  require_relative "turnkit/prompt_data"
31
32
  require_relative "turnkit/prompt_context"
32
- require_relative "turnkit/prompt_contribution"
33
33
  require_relative "turnkit/system_prompt"
34
34
  require_relative "turnkit/store"
35
35
  require_relative "turnkit/memory_store"
36
36
  require_relative "turnkit/compaction"
37
37
  require_relative "turnkit/tool"
38
38
  require_relative "turnkit/image_tool"
39
+ require_relative "turnkit/view_media_tool"
39
40
  require_relative "turnkit/tool_call"
40
41
  require_relative "turnkit/tool_execution"
41
42
  require_relative "turnkit/sub_agent_tool"
@@ -47,9 +48,7 @@ require_relative "turnkit/usage"
47
48
  require_relative "turnkit/run"
48
49
  require_relative "turnkit/adapters/codex"
49
50
  require_relative "turnkit/adapters/ruby_llm"
50
- require_relative "turnkit/stores/active_record_store"
51
-
52
- require_relative "turnkit/rails/railtie" if defined?(Rails)
51
+ require_relative "turnkit/active_record_store"
53
52
 
54
53
  module TurnKit
55
54
  class << self
@@ -62,10 +61,7 @@ module TurnKit
62
61
  attr_accessor :cost_rates, :cost_calculator
63
62
  attr_accessor :prompt_sections, :prompt_behavior, :available_skills
64
63
  attr_accessor :prompt_data_max_chars, :context_contributors
65
- attr_accessor :system_prompt_contributors, :model_prompt_contributors
66
64
  attr_accessor :on_event
67
- attr_accessor :conversation_record_class, :turn_record_class
68
- attr_accessor :message_record_class, :tool_execution_record_class
69
65
  end
70
66
 
71
67
  self.default_model = "claude-sonnet-4-5"
@@ -84,8 +80,6 @@ module TurnKit
84
80
  self.prompt_data_max_chars = 20_000
85
81
  self.available_skills = []
86
82
  self.context_contributors = []
87
- self.system_prompt_contributors = []
88
- self.model_prompt_contributors = {}
89
83
  self.on_event = nil
90
84
  self.output_policy_model = nil
91
85
  self.output_policy_thinking = { effort: :low }
@@ -94,14 +88,6 @@ module TurnKit
94
88
  yield self
95
89
  end
96
90
 
97
- def self.model
98
- default_model
99
- end
100
-
101
- def self.model=(value)
102
- self.default_model = value
103
- end
104
-
105
91
  def self.reconcile_stale!(before: Clock.now - (timeout || 300))
106
92
  store.find_stale_turns(before: before).each do |turn|
107
93
  store.update_turn(turn.fetch("id"), "status" => "stale", "completed_at" => Clock.now)
@@ -116,4 +102,9 @@ module TurnKit
116
102
  image_client = client || self.client
117
103
  image_client.paint(prompt: prompt, model: model, provider: provider, size: size, assume_model_exists: assume_model_exists, input_images: input_images, mask: mask, params: params, metadata: metadata).images.first
118
104
  end
105
+
106
+ def self.view_media(media, objective:, model:, provider: nil, output_schema: nil, params: {}, metadata: {}, client: nil)
107
+ media_client = client || self.client
108
+ media_client.view_media(media: media, objective: objective, model: model, provider: provider, output_schema: output_schema, params: params, metadata: metadata).media_analyses.first
109
+ end
119
110
  end
metadata CHANGED
@@ -1,31 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turnkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-19 00:00:00.000000000 Z
12
- dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: ruby_llm
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: '1.14'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - "~>"
25
- - !ruby/object:Gem::Version
26
- version: '1.14'
11
+ date: 2026-07-02 00:00:00.000000000 Z
12
+ dependencies: []
27
13
  description: TurnKit is a Ruby/Rails agent runtime for durable AI conversations, application
28
- runs, reusable workflows, tool calling, skills, sub-agents, context compaction,
14
+ runs, orchestrator agents, tool calling, skills, sub-agents, context compaction,
29
15
  and persistence.
30
16
  email:
31
17
  - sam@samcouch.com
@@ -37,7 +23,16 @@ files:
37
23
  - LICENSE.md
38
24
  - README.md
39
25
  - UPGRADE.md
26
+ - UPGRADE_TO_0_4_2.md
27
+ - lib/generators/turnkit/install/templates/conversation.rb
28
+ - lib/generators/turnkit/install/templates/create_turnkit_tables.rb
29
+ - lib/generators/turnkit/install/templates/initializer.rb
30
+ - lib/generators/turnkit/install/templates/message.rb
31
+ - lib/generators/turnkit/install/templates/tool_execution.rb
32
+ - lib/generators/turnkit/install/templates/turn.rb
33
+ - lib/generators/turnkit/install_generator.rb
40
34
  - lib/turnkit.rb
35
+ - lib/turnkit/active_record_store.rb
41
36
  - lib/turnkit/adapters/codex.rb
42
37
  - lib/turnkit/adapters/ruby_llm.rb
43
38
  - lib/turnkit/agent.rb
@@ -49,17 +44,12 @@ files:
49
44
  - lib/turnkit/cost.rb
50
45
  - lib/turnkit/error.rb
51
46
  - lib/turnkit/event.rb
52
- - lib/turnkit/generators/turnkit/install/templates/conversation.rb
53
- - lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb
54
- - lib/turnkit/generators/turnkit/install/templates/initializer.rb
55
- - lib/turnkit/generators/turnkit/install/templates/message.rb
56
- - lib/turnkit/generators/turnkit/install/templates/tool_execution.rb
57
- - lib/turnkit/generators/turnkit/install/templates/turn.rb
58
- - lib/turnkit/generators/turnkit/install_generator.rb
59
47
  - lib/turnkit/id.rb
60
48
  - lib/turnkit/image_result.rb
61
49
  - lib/turnkit/image_tool.rb
62
50
  - lib/turnkit/load_skill_tool.rb
51
+ - lib/turnkit/media_analysis_result.rb
52
+ - lib/turnkit/media_input.rb
63
53
  - lib/turnkit/memory_store.rb
64
54
  - lib/turnkit/message.rb
65
55
  - lib/turnkit/message_projection.rb
@@ -67,16 +57,13 @@ files:
67
57
  - lib/turnkit/output_audit.rb
68
58
  - lib/turnkit/output_policy.rb
69
59
  - lib/turnkit/prompt_context.rb
70
- - lib/turnkit/prompt_contribution.rb
71
60
  - lib/turnkit/prompt_data.rb
72
- - lib/turnkit/rails/railtie.rb
73
61
  - lib/turnkit/record.rb
74
62
  - lib/turnkit/result.rb
75
63
  - lib/turnkit/run.rb
76
64
  - lib/turnkit/schema_check.rb
77
65
  - lib/turnkit/skill.rb
78
66
  - lib/turnkit/store.rb
79
- - lib/turnkit/stores/active_record_store.rb
80
67
  - lib/turnkit/sub_agent_tool.rb
81
68
  - lib/turnkit/system_prompt.rb
82
69
  - lib/turnkit/tool.rb
@@ -86,7 +73,7 @@ files:
86
73
  - lib/turnkit/turn.rb
87
74
  - lib/turnkit/usage.rb
88
75
  - lib/turnkit/version.rb
89
- - lib/turnkit/workflow.rb
76
+ - lib/turnkit/view_media_tool.rb
90
77
  homepage: https://github.com/samuelcouch/turnkit
91
78
  licenses:
92
79
  - MIT
@@ -114,5 +101,6 @@ requirements: []
114
101
  rubygems_version: 3.5.22
115
102
  signing_key:
116
103
  specification_version: 4
117
- summary: Ruby/Rails agent runtime for durable AI conversations, runs, and workflows.
104
+ summary: Ruby/Rails agent runtime for durable AI conversations, runs, and orchestrator
105
+ agents.
118
106
  test_files: []
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TurnKit
4
- class PromptContribution
5
- attr_accessor :stable_prefix, :dynamic_suffix, :section_overrides
6
-
7
- def initialize(stable_prefix: nil, dynamic_suffix: nil, section_overrides: nil)
8
- @stable_prefix = stable_prefix.to_s
9
- @dynamic_suffix = dynamic_suffix.to_s
10
- @section_overrides = (section_overrides || {}).transform_keys(&:to_sym)
11
- end
12
- end
13
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TurnKit
4
- class Railtie < Rails::Railtie
5
- generators do
6
- require_relative "../generators/turnkit/install_generator"
7
- end
8
- end
9
- end
@@ -1,58 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "agent"
4
-
5
- module TurnKit
6
- class Workflow
7
- ORCHESTRATOR_PREAMBLE = <<~TEXT.strip
8
- You are an autonomous task orchestrator. Navigate from the application
9
- request to a final output without asking the user follow-up questions.
10
-
11
- Use the available tools to gather context, inspect sources, take actions,
12
- persist outputs, and verify work. Use loaded skills as reusable workflow
13
- patterns. Iterate when work needs missing context, critique, revision, or
14
- verification.
15
-
16
- When multiple independent items need the same kind of fetch or read, and
17
- an available batch tool can handle them in one call, prefer the batch tool
18
- over repeated one-item tool calls.
19
-
20
- Stop when the task is complete, when the available context and tools are
21
- sufficient for the best possible answer, or when further iteration would
22
- not materially improve the result. Respect runtime, cost, and iteration
23
- limits.
24
- TEXT
25
-
26
- DEFAULT_INSTRUCTIONS = ORCHESTRATOR_PREAMBLE
27
-
28
- attr_reader :name, :options
29
-
30
- def initialize(name: "workflow", instructions: nil, preamble: true, **options)
31
- @name = name.to_s
32
- raise ArgumentError, "name is required" if @name.empty?
33
-
34
- @options = options.merge(
35
- name: @name,
36
- prompt_mode: options.fetch(:prompt_mode, :task),
37
- instructions: compose_instructions(instructions, preamble: preamble)
38
- ).freeze
39
- @agent = Agent.new(**@options)
40
- end
41
-
42
- def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {}, **overrides)
43
- agent(**overrides).run(task || prompt, input: input, async: async, subject: subject, metadata: metadata)
44
- end
45
-
46
- def agent(**overrides)
47
- overrides.empty? ? @agent : Agent.new(**@options.merge(overrides.compact))
48
- end
49
-
50
- private
51
- def compose_instructions(instructions, preamble:)
52
- parts = []
53
- parts << ORCHESTRATOR_PREAMBLE if preamble
54
- parts << instructions.to_s.strip unless instructions.to_s.strip.empty?
55
- parts.join("\n\n")
56
- end
57
- end
58
- end