turnkit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5694bc97b2f735e5076574e2863ee5addc41926bd85edf02e1835263ffb3516
4
- data.tar.gz: 65286330a1d0b4bbd0e3e6c11ba73abd836fb22a44ae4b3ab48a58ecf9d19425
3
+ metadata.gz: 2b34c1c2760df56b69b055246a8afcccaec42c5493cce13ca30303c9c7e1e809
4
+ data.tar.gz: d3ff91766661eddbd6bb8eefba627ceaf13d8d2b2daa673781819cfe71b41a79
5
5
  SHA512:
6
- metadata.gz: 2b3674abf0cae37286a04431f0ceb02a30e282c715e4d6d96e51c0a08d600c94a9fee6c82bf178c0b97ff080ee221b00a18b5409e72003d92c7a5430b34d5733
7
- data.tar.gz: 7141f5cc00df42bfaf0e9b035d75f54e0b7c9b14ff71a8c95805242b32835fb410358f7f42ff2161f89896425b62fd840c05dcc2504f555430450517dc61bf9b
6
+ metadata.gz: 02a3e913bcfa72bc5fcbd8e18faaf60def5f62b75981c7e72220e994b7874df2a67fd306dd01cde4f9c3a0d5082aa90271bf210c5c4c5876a5015812077df79f
7
+ data.tar.gz: 0d6d917eeeef41a3a624a31daccdc60f28e4c121b8959ad0ab3d57301be63427e83f2a349a920fa9707bd9f5e2fa2310721f72a8151fbbb44c26a3ec75466259
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-06-19
4
+
5
+ - Add first-class image generation with `Turn#paint`, `TurnKit.paint`, and `TurnKit::ImageTool`.
6
+ - Persist generated images as durable image messages with normalized metadata, usage, cost, and event callbacks.
7
+ - Add image output policy support and a `generate-image` CLI smoke example for Gemini 16:9 image generation.
8
+
3
9
  ## 0.3.0 - 2026-06-10
4
10
 
5
11
  - Make the task-runtime API skills-first and intentionally breaking: `max_spend` is the only spend-limit name and output validation is exposed as `output_policy` / `policy_audit`.
data/README.md CHANGED
@@ -459,6 +459,61 @@ puts turn.output_text
459
459
 
460
460
  Rely on TurnKit to validate tools and model-provided arguments.
461
461
 
462
+ ### Images
463
+
464
+ Generate images inside a durable turn with `turn.paint`. The image call uses the
465
+ configured client adapter, records usage and cost on the turn, persists an image
466
+ message, and emits `image.requested` / `image.completed` events.
467
+
468
+ ```ruby
469
+ image = turn.paint(
470
+ "Create a 16:9 editorial header image for the article.",
471
+ model: "gemini-3-pro-image-preview",
472
+ provider: :gemini,
473
+ size: "1024x576",
474
+ metadata: { article_id: article.id }
475
+ )
476
+
477
+ image.url # provider-hosted URL when returned
478
+ image.to_blob # generated bytes for base64 responses, or fetched URL bytes
479
+ image.mime_type # "image/png"
480
+ ```
481
+
482
+ For reusable workflow steps, subclass `TurnKit::ImageTool`:
483
+
484
+ ```ruby
485
+ class GenerateHeaderImage < TurnKit::ImageTool
486
+ description "Generate an article header image."
487
+ parameter :title, :string, required: true
488
+
489
+ model "gemini-3-pro-image-preview"
490
+ provider :gemini
491
+ size "1024x576"
492
+
493
+ def prompt(title:)
494
+ "Create a 16:9 editorial header image for #{title}."
495
+ end
496
+ end
497
+ ```
498
+
499
+ Rails apps can attach generated images from the event stream without TurnKit
500
+ taking a dependency on Active Storage:
501
+
502
+ ```ruby
503
+ TurnKit.on_event = ->(event) do
504
+ next unless event.type == "image.completed"
505
+
506
+ image = TurnKit::ImageResult.from_h(event.payload.fetch(:image))
507
+ Article.find(event.payload.dig(:metadata, :article_id)).header_image.attach(
508
+ io: StringIO.new(image.to_blob),
509
+ filename: "header.png",
510
+ content_type: image.mime_type
511
+ )
512
+ end
513
+ ```
514
+
515
+ Require an image before completion with `TurnKit::OutputPolicy.require_image`.
516
+
462
517
  ### Structured Output
463
518
 
464
519
  Define a schema:
@@ -41,6 +41,23 @@ module TurnKit
41
41
  normalize_response(response, model: model)
42
42
  end
43
43
 
44
+ 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
+
47
+ configure_from_environment
48
+ kwargs = paint_kwargs(
49
+ model: model,
50
+ provider: provider,
51
+ assume_model_exists: assume_model_exists || false,
52
+ size: size || "1024x1024",
53
+ with: input_images,
54
+ mask: mask,
55
+ params: params || {}
56
+ )
57
+ image = ::RubyLLM.paint(prompt, **kwargs)
58
+ normalize_image_response(image, model: model, provider: provider, params: { "size" => size || "1024x1024" }.merge(params || {}), metadata: metadata)
59
+ end
60
+
44
61
  private
45
62
  def configure_from_environment
46
63
  config = ::RubyLLM.config
@@ -246,6 +263,47 @@ module TurnKit
246
263
 
247
264
  response.cost&.total
248
265
  end
266
+
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
+ def normalize_image_response(image, model:, provider:, params:, metadata:)
283
+ usage = Usage.new(
284
+ input_tokens: image_usage_value(image, "input_tokens"),
285
+ output_tokens: image_usage_value(image, "output_tokens"),
286
+ cost: response_cost(image)
287
+ )
288
+ part = ImageResult.new(
289
+ url: image.respond_to?(:url) ? image.url : nil,
290
+ data: image.respond_to?(:data) ? image.data : nil,
291
+ mime_type: image.respond_to?(:mime_type) ? image.mime_type : nil,
292
+ revised_prompt: image.respond_to?(:revised_prompt) ? image.revised_prompt : nil,
293
+ model: image.respond_to?(:model_id) ? image.model_id : model,
294
+ provider: provider&.to_s,
295
+ usage: usage,
296
+ params: params,
297
+ metadata: metadata || {}
298
+ ).to_h.merge("type" => "image")
299
+
300
+ Result.new(parts: [ part ], usage: usage, model: part["model"], output_data: { "type" => "image", "images" => [ part ] })
301
+ end
302
+
303
+ def image_usage_value(image, key)
304
+ usage = image.respond_to?(:usage) ? image.usage || {} : {}
305
+ (usage[key] || usage[key.to_sym]).to_i
306
+ end
249
307
  end
250
308
  end
251
309
  end
@@ -9,5 +9,9 @@ module TurnKit
9
9
  def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
10
10
  raise NotImplementedError
11
11
  end
12
+
13
+ def paint(prompt:, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: nil, on_event: nil)
14
+ raise NotImplementedError
15
+ end
12
16
  end
13
17
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "open-uri"
5
+
6
+ module TurnKit
7
+ class ImageResult
8
+ attr_reader :url, :data, :mime_type, :revised_prompt, :model, :provider, :usage, :params, :metadata
9
+
10
+ def self.from_h(value)
11
+ new(**value.transform_keys(&:to_sym))
12
+ end
13
+
14
+ def initialize(url: nil, data: nil, mime_type: nil, revised_prompt: nil, model: nil, provider: nil, usage: Usage.new, params: {}, metadata: {}, **)
15
+ @url = url
16
+ @data = data
17
+ @mime_type = mime_type
18
+ @revised_prompt = revised_prompt
19
+ @model = model
20
+ @provider = provider
21
+ @usage = usage.is_a?(Usage) ? usage : Usage.from_h(usage || {})
22
+ @params = params || {}
23
+ @metadata = metadata || {}
24
+ end
25
+
26
+ def to_blob
27
+ raise Error, "image has no url or data" if url.to_s.empty? && data.to_s.empty?
28
+
29
+ data ? Base64.decode64(data) : URI.open(url, &:read)
30
+ end
31
+
32
+ def cost
33
+ Cost.from_usage(usage, model: model)
34
+ end
35
+
36
+ def to_h
37
+ {
38
+ "url" => url,
39
+ "data" => data,
40
+ "mime_type" => mime_type,
41
+ "revised_prompt" => revised_prompt,
42
+ "model" => model,
43
+ "provider" => provider,
44
+ "usage" => usage.to_h,
45
+ "cost" => cost.to_h,
46
+ "params" => params,
47
+ "metadata" => metadata
48
+ }.compact
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class ImageTool < Tool
5
+ class << self
6
+ %i[model provider size assume_model_exists 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(turnkit_context:, **arguments)
15
+ turnkit_context.turn.paint(
16
+ prompt(**arguments),
17
+ model: self.class.model,
18
+ provider: self.class.provider,
19
+ size: self.class.size,
20
+ assume_model_exists: self.class.assume_model_exists,
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
@@ -3,7 +3,7 @@
3
3
  module TurnKit
4
4
  class Message
5
5
  ROLES = %w[user assistant tool].freeze
6
- KINDS = %w[text tool_call tool_result context_summary].freeze
6
+ KINDS = %w[text tool_call tool_result context_summary image].freeze
7
7
 
8
8
  attr_reader :id, :conversation_id, :turn_id, :role, :kind, :sequence
9
9
  attr_reader :content, :tool_execution_id, :provider_message_id, :metadata, :created_at
@@ -57,6 +57,10 @@ module TurnKit
57
57
  kind == "context_summary"
58
58
  end
59
59
 
60
+ def image?
61
+ kind == "image"
62
+ end
63
+
60
64
  def text
61
65
  content.filter_map do |part|
62
66
  attrs = stringify(part)
@@ -44,6 +44,8 @@ module TurnKit
44
44
  when "tool_result"
45
45
  part = message.content.find { |candidate| candidate.fetch("type") == "tool_result" }
46
46
  { role: :tool, content: part&.fetch("text", message.text) || message.text, tool_call_id: part&.fetch("tool_call_id", nil) }
47
+ when "image"
48
+ { role: :assistant, content: projected_images }
47
49
  else
48
50
  { role: message.role.to_sym, content: message.text }
49
51
  end
@@ -65,5 +67,14 @@ module TurnKit
65
67
  { "id" => part.fetch("id"), "name" => part.fetch("name"), "arguments" => part["arguments"] || {} }
66
68
  end
67
69
  end
70
+
71
+ def projected_images
72
+ message.content.filter_map do |part|
73
+ next unless part.fetch("type") == "image"
74
+
75
+ attrs = part.slice("url", "mime_type", "model", "provider", "revised_prompt").compact
76
+ "Generated image: #{attrs.to_json}"
77
+ end.join("\n")
78
+ end
68
79
  end
69
80
  end
@@ -31,6 +31,15 @@ module TurnKit
31
31
  new(name: skill.key, content: skill.content, **options)
32
32
  end
33
33
 
34
+ def self.require_image
35
+ lambda do |output, output_data: nil, turn: nil, **|
36
+ data = output_data.is_a?(Hash) ? output_data : output
37
+ images = data.is_a?(Hash) ? data["images"] || data[:images] : nil
38
+ has_image = Array(images).any? || turn&.conversation&.messages_for_turn(turn)&.any?(&:image?)
39
+ { rule: "image_required", message: "output must include an image result" } unless has_image
40
+ end
41
+ end
42
+
34
43
  def initialize(content:, name: "output_policy", model: nil, thinking: nil, client: nil)
35
44
  @name = name.to_s
36
45
  @content = content.to_s
@@ -28,6 +28,18 @@ module TurnKit
28
28
  tool_calls.any?
29
29
  end
30
30
 
31
+ def images
32
+ parts.filter_map do |part|
33
+ next unless part["type"] == "image"
34
+
35
+ ImageResult.from_h(part)
36
+ end
37
+ end
38
+
39
+ def image?
40
+ images.any?
41
+ end
42
+
31
43
  private
32
44
  def synthesize_parts(text:, tool_calls:)
33
45
  parts = []
@@ -49,6 +49,9 @@ module TurnKit
49
49
  context = ToolContext.new(turn: turn, execution: execution)
50
50
  payload = begin
51
51
  normalize_payload(call_tool(tool, tool_call.arguments, context: context))
52
+ rescue BudgetError => error
53
+ finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
54
+ raise
52
55
  rescue StandardError => error
53
56
  return finish_error(execution, tool_call, error.message, details: { "class" => error.class.name })
54
57
  end
data/lib/turnkit/turn.rb CHANGED
@@ -167,6 +167,59 @@ module TurnKit
167
167
  result
168
168
  end
169
169
 
170
+ 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
+
177
+ @record = claimed
178
+ @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
+ 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"
186
+ end
187
+
188
+ image_client = client || agent.effective_client
189
+ request = {
190
+ prompt: prompt,
191
+ model: model,
192
+ provider: provider,
193
+ size: size,
194
+ assume_model_exists: assume_model_exists,
195
+ input_images: input_images,
196
+ mask: mask,
197
+ params: params || {},
198
+ metadata: { turn_id: id, conversation_id: conversation.id }.merge(metadata || {})
199
+ }
200
+
201
+ image_client.validate!(model: model)
202
+ emit("image.requested", request.except(:input_images, :mask))
203
+ 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)
207
+ image = result.images.first
208
+ raise Error, "image client returned no image" unless image
209
+ raise Error, "image client returned image without url or data" if image.url.to_s.empty? && image.data.to_s.empty?
210
+
211
+ persist_image_message(image)
212
+ 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
214
+ image
215
+ 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
220
+ raise
221
+ end
222
+
170
223
  private
171
224
  def model_request
172
225
  prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
@@ -214,6 +267,16 @@ module TurnKit
214
267
  end
215
268
  end
216
269
 
270
+ 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
274
+
275
+ name if %i[key keyreq].include?(kind)
276
+ end
277
+ client.paint(**kwargs.slice(*accepted))
278
+ end
279
+
217
280
  def llm_messages
218
281
  MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
219
282
  end
@@ -271,12 +334,20 @@ module TurnKit
271
334
  )
272
335
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
273
336
  result.tool_calls.each { |call| emit("tool_call.created", id: call.id, name: call.name) }
337
+ elsif result.image?
338
+ 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
+ emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
274
340
  else
275
341
  message = conversation.append_message(role: "assistant", kind: "text", text: result.text, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
276
342
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
277
343
  end
278
344
  end
279
345
 
346
+ def persist_image_message(image)
347
+ message = conversation.append_message(role: "assistant", kind: "image", content: [ image.to_h.merge("type" => "image") ], turn_id: id, metadata: { "output_data" => { "type" => "image", "images" => [ image.to_h ] } })
348
+ emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
349
+ end
350
+
280
351
  def append_terminal_completion(runner, execution)
281
352
  message = runner.completion_message(execution)
282
353
  assistant = conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/turnkit.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "turnkit/client"
22
22
  require_relative "turnkit/conversation"
23
23
  require_relative "turnkit/message"
24
24
  require_relative "turnkit/record"
25
+ require_relative "turnkit/image_result"
25
26
  require_relative "turnkit/result"
26
27
  require_relative "turnkit/skill"
27
28
  require_relative "turnkit/output_audit"
@@ -34,6 +35,7 @@ require_relative "turnkit/store"
34
35
  require_relative "turnkit/memory_store"
35
36
  require_relative "turnkit/compaction"
36
37
  require_relative "turnkit/tool"
38
+ require_relative "turnkit/image_tool"
37
39
  require_relative "turnkit/tool_call"
38
40
  require_relative "turnkit/tool_execution"
39
41
  require_relative "turnkit/sub_agent_tool"
@@ -109,4 +111,9 @@ module TurnKit
109
111
  def self.check_output_policy(output, constraints: [], context: {})
110
112
  OutputAudit.check(output, constraints: constraints, context: context)
111
113
  end
114
+
115
+ def self.paint(prompt, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: {}, client: nil)
116
+ image_client = client || self.client
117
+ 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
+ end
112
119
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turnkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
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-10 00:00:00.000000000 Z
11
+ date: 2026-06-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -57,6 +57,8 @@ files:
57
57
  - lib/turnkit/generators/turnkit/install/templates/turn.rb
58
58
  - lib/turnkit/generators/turnkit/install_generator.rb
59
59
  - lib/turnkit/id.rb
60
+ - lib/turnkit/image_result.rb
61
+ - lib/turnkit/image_tool.rb
60
62
  - lib/turnkit/load_skill_tool.rb
61
63
  - lib/turnkit/memory_store.rb
62
64
  - lib/turnkit/message.rb