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
@@ -0,0 +1,425 @@
1
+ # Upgrade to TurnKit 0.4.2
2
+
3
+ This guide is for upgrading existing TurnKit apps to `0.4.2` from older
4
+ pre-1.0 versions.
5
+
6
+ ## Audit summary
7
+
8
+ The current branch is not a patch-only dependency bump. It removes several
9
+ short-lived public APIs and consolidates the task-runner surface around
10
+ `TurnKit::Agent`. Treat `0.4.2` as a breaking pre-1.0 upgrade unless the removed
11
+ compatibility shims are restored before release.
12
+
13
+ If you only use plain `Conversation` turns with the built-in memory store and no
14
+ custom tools, clients, prompt contributors, workflows, or Rails store overrides,
15
+ the upgrade is usually small. Apps using workflow/orchestrator APIs should follow
16
+ the checklist below.
17
+
18
+ ## 1. Update your Gemfile
19
+
20
+ ```ruby
21
+ gem "turnkit", "0.4.2"
22
+ ```
23
+
24
+ If you use TurnKit's default RubyLLM adapter, add RubyLLM explicitly. TurnKit no
25
+ longer depends on it at runtime.
26
+
27
+ ```ruby
28
+ gem "ruby_llm", "~> 1.16"
29
+ ```
30
+
31
+ Codex adapter users and apps with a custom `TurnKit::Client` do not need
32
+ `ruby_llm` unless they also use `TurnKit::Adapters::RubyLLM`.
33
+
34
+ Then update your bundle:
35
+
36
+ ```sh
37
+ bundle update turnkit
38
+ bundle update ruby_llm # only if you added or already use ruby_llm
39
+ ```
40
+
41
+ ## 2. Replace workflows with orchestrator agents
42
+
43
+ `TurnKit::Workflow` was removed. Use `TurnKit::Agent` with
44
+ `orchestrator: true` instead.
45
+
46
+ Before:
47
+
48
+ ```ruby
49
+ workflow = TurnKit::Workflow.new(
50
+ name: "brief_writer",
51
+ instructions: "Create source-grounded briefs.",
52
+ skills: [source_grounded_brief],
53
+ tools: [WebSearch, ReadWebPage],
54
+ max_spend: 0.25
55
+ )
56
+
57
+ run = workflow.run(
58
+ "Create a brief.",
59
+ input: { topic: topic }
60
+ )
61
+ ```
62
+
63
+ After:
64
+
65
+ ```ruby
66
+ agent = TurnKit::Agent.new(
67
+ name: "brief_writer",
68
+ orchestrator: true,
69
+ instructions: "Create source-grounded briefs.",
70
+ skills: [source_grounded_brief],
71
+ tools: [WebSearch, ReadWebPage],
72
+ max_spend: 0.25
73
+ )
74
+
75
+ run = agent.run(
76
+ "Create a brief.",
77
+ input: { topic: topic }
78
+ )
79
+ ```
80
+
81
+ Notes:
82
+
83
+ - `orchestrator: true` prepends the same autonomous-task preamble that workflows
84
+ used and defaults the agent prompt mode to `:task`.
85
+ - `workflow.agent(**overrides)` no longer exists. Construct another
86
+ `TurnKit::Agent` with the desired options.
87
+ - Per-run workflow overrides such as `workflow.run(..., max_spend: 0.10)` no
88
+ longer have a direct equivalent. Configure those limits on the agent; agents
89
+ are cheap to construct.
90
+
91
+ ## 3. Update `Agent#run` call sites
92
+
93
+ `Agent#run` now requires the task as the first positional argument.
94
+
95
+ ```ruby
96
+ # Before
97
+ agent.run(task: "Classify this lead.", input: lead)
98
+
99
+ # After
100
+ agent.run("Classify this lead.", input: lead)
101
+ ```
102
+
103
+ `input:`, `async:`, `subject:`, `metadata:`, and other run options remain keyword
104
+ arguments.
105
+
106
+ ## 4. Rename global model configuration
107
+
108
+ The temporary `TurnKit.model` aliases were removed.
109
+
110
+ ```ruby
111
+ # Before
112
+ TurnKit.model = "gpt-4.1-mini"
113
+ model = TurnKit.model
114
+
115
+ # After
116
+ TurnKit.default_model = "gpt-4.1-mini"
117
+ model = TurnKit.default_model
118
+ ```
119
+
120
+ In `TurnKit.configure`, use `config.default_model = ...`.
121
+
122
+ ## 5. Rename run accessors
123
+
124
+ Update code that reads completed run results or tool activity:
125
+
126
+ | Before | After |
127
+ | --- | --- |
128
+ | `run.output` | `run.output_text` |
129
+ | `run.tool_calls` | `run.tool_executions` |
130
+ | `run.steps` | `run.turn_records.length` if you need the count |
131
+ | `run.persisted?` | remove; runs are persisted wrappers around turns |
132
+
133
+ `run.output_data`, `run.usage`, `run.cost`, `run.policy_audit`, and
134
+ `run.policy_clean?` remain available.
135
+
136
+ ## 6. Update custom tools
137
+
138
+ TurnKit now passes framework context only as `context:`. The older
139
+ `turnkit_context:` keyword was removed.
140
+
141
+ ```ruby
142
+ # Before
143
+ def call(title:, body:, turnkit_context:)
144
+ turn = turnkit_context.turn
145
+ { id: SaveReport.call(title:, body:, turn:) }
146
+ end
147
+
148
+ # After
149
+ def call(title:, body:, context:)
150
+ turn = context.turn
151
+ { id: SaveReport.call(title:, body:, turn:) }
152
+ end
153
+ ```
154
+
155
+ `context` is now a reserved tool parameter name. Do not expose a model-visible
156
+ tool parameter named `context`:
157
+
158
+ ```ruby
159
+ # Do not do this in 0.4.2
160
+ parameter :context, :string
161
+ ```
162
+
163
+ If a tool class needs constructor arguments, register an instance instead of the
164
+ class:
165
+
166
+ ```ruby
167
+ agent = TurnKit::Agent.new(
168
+ name: "researcher",
169
+ tools: [WebSearch.new(client: SearchClient.new)]
170
+ )
171
+ ```
172
+
173
+ ## 7. Update sub-agent calls
174
+
175
+ `TurnKit::SubAgentTool` exposes a single model-visible `task` parameter. If your
176
+ sub-agent usage previously separated `task` and `context`, include the necessary
177
+ context in the task text itself.
178
+
179
+ Before:
180
+
181
+ ```json
182
+ { "task": "Draft headlines", "context": "Product: PhotoDay" }
183
+ ```
184
+
185
+ After:
186
+
187
+ ```json
188
+ { "task": "Draft headlines for Product: PhotoDay." }
189
+ ```
190
+
191
+ ## 8. Replace prompt contributor APIs
192
+
193
+ These prompt extension APIs were removed:
194
+
195
+ - `TurnKit.system_prompt_contributors`
196
+ - `TurnKit.model_prompt_contributors`
197
+ - `TurnKit::PromptContribution`
198
+ - prompt section override objects
199
+ - `TurnKit::SystemPrompt::CACHE_BOUNDARY`
200
+ - `TurnKit::SystemPrompt.split_cache_boundary`
201
+
202
+ Use per-agent `system_prompt:` instead. A string replaces the generated prompt.
203
+ A callable receives a `TurnKit::SystemPrompt` object.
204
+
205
+ ```ruby
206
+ agent = TurnKit::Agent.new(
207
+ name: "reporter",
208
+ system_prompt: ->(prompt) {
209
+ [
210
+ prompt.stable,
211
+ prompt.section(:tools),
212
+ prompt.dynamic
213
+ ].reject(&:empty?).join("\n\n")
214
+ }
215
+ )
216
+ ```
217
+
218
+ Available generated-prompt APIs:
219
+
220
+ - `prompt.to_s`
221
+ - `prompt.stable`
222
+ - `prompt.dynamic`
223
+ - `prompt.section(:tools)` and other section names
224
+
225
+ `TurnKit.prompt_sections`, per-agent `prompt_sections:`,
226
+ `TurnKit.prompt_behavior`, and `TurnKit.context_contributors` remain available.
227
+
228
+ ## 9. Update custom clients and adapters
229
+
230
+ TurnKit no longer sniffs custom client method signatures before calling them.
231
+ Custom clients should subclass `TurnKit::Client` or accept the full keyword
232
+ contracts.
233
+
234
+ At minimum, update `chat` to accept `dynamic_instructions:`:
235
+
236
+ ```ruby
237
+ def chat(model:, messages:, tools:, instructions:, dynamic_instructions: nil,
238
+ temperature: nil, thinking: nil, output_schema: nil, metadata: nil,
239
+ on_event: nil)
240
+ full_prompt = [instructions, dynamic_instructions].compact.join("\n\n")
241
+ # call provider...
242
+ end
243
+ ```
244
+
245
+ If your client supports image generation or media analysis, also accept the full
246
+ `paint` and `view_media` keyword sets from `TurnKit::Client`, including
247
+ `metadata:` and `on_event:`.
248
+
249
+ Prompt caching changed from an embedded cache-boundary marker to explicit stable
250
+ and dynamic instructions. Adapters that support provider prompt caching should
251
+ cache `instructions` and append `dynamic_instructions` per turn.
252
+
253
+ ## 10. Update custom stores
254
+
255
+ Built-in `TurnKit::MemoryStore` and `TurnKit::ActiveRecordStore` already support
256
+ the new claiming behavior.
257
+
258
+ If you maintain a custom store, implement `claim_turn` as an atomic
259
+ compare-and-set from `from` to `to`. It must return the claimed turn record when
260
+ the claim succeeds and `nil` when another worker already claimed or completed
261
+ the turn.
262
+
263
+ ```ruby
264
+ def claim_turn(id, from: "pending", to: "running", **attributes)
265
+ # Atomically update only when uid/id and status both match.
266
+ # Return nil if no row was updated.
267
+ end
268
+ ```
269
+
270
+ The old default implementation was intentionally removed because it was not safe
271
+ for concurrent workers.
272
+
273
+ ## 11. Update Rails store configuration
274
+
275
+ The generated table and model names remain compatible, but custom Active Record
276
+ class configuration moved from global TurnKit attributes to the store
277
+ constructor.
278
+
279
+ Before:
280
+
281
+ ```ruby
282
+ TurnKit.store = TurnKit::ActiveRecordStore.new
283
+ TurnKit.conversation_record_class = "My::Conversation"
284
+ TurnKit.turn_record_class = "My::Turn"
285
+ TurnKit.message_record_class = "My::Message"
286
+ TurnKit.tool_execution_record_class = "My::ToolExecution"
287
+ ```
288
+
289
+ After:
290
+
291
+ ```ruby
292
+ TurnKit.store = TurnKit::ActiveRecordStore.new(
293
+ conversation_class: "My::Conversation",
294
+ turn_class: "My::Turn",
295
+ message_class: "My::Message",
296
+ tool_execution_class: "My::ToolExecution"
297
+ )
298
+ ```
299
+
300
+ Apps that used `require "turnkit"` can keep doing that. If you directly required
301
+ the old internal Active Record store path, update it:
302
+
303
+ ```ruby
304
+ # Before
305
+ require "turnkit/stores/active_record_store"
306
+
307
+ # After
308
+ require "turnkit/active_record_store"
309
+ ```
310
+
311
+ If you manually required `turnkit/rails/railtie`, remove that require. The Rails
312
+ install generator now lives at the conventional `lib/generators` path and should
313
+ still be available as:
314
+
315
+ ```sh
316
+ bin/rails generate turnkit:install
317
+ ```
318
+
319
+ ## 12. Check database migrations for older Rails installs
320
+
321
+ New installs already include all current columns.
322
+
323
+ Existing Rails installs from before structured/image/media output persistence
324
+ should add `output_data` to turns if they do not already have it:
325
+
326
+ ```ruby
327
+ class AddOutputDataToTurnkitTurns < ActiveRecord::Migration[7.1]
328
+ def change
329
+ add_column :turnkit_turns, :output_data, :json
330
+ end
331
+ end
332
+ ```
333
+
334
+ If you customized the table prefix when installing TurnKit, use your actual turns
335
+ table name instead of `turnkit_turns`.
336
+
337
+ Upgrades from the `0.2.x` series also need the `0.3.0` message-schema migration:
338
+ message content is stored as ordered typed parts in `turnkit_messages.content`,
339
+ with text derived from content. For greenfield or disposable development data,
340
+ the simplest route is usually to regenerate the current install migration and
341
+ recreate the TurnKit tables.
342
+
343
+ ## 13. Update custom cost rates
344
+
345
+ `TurnKit.cost_rates` now accepts only these component keys, expressed as USD per
346
+ million tokens:
347
+
348
+ - `input`
349
+ - `output`
350
+ - `cache_read`
351
+ - `cache_write`
352
+ - `thinking`
353
+
354
+ Before:
355
+
356
+ ```ruby
357
+ TurnKit.cost_rates["gpt-5"] = {
358
+ input: 1.25,
359
+ output: 10.0,
360
+ cached_input: 0.125,
361
+ reasoning: 10.0
362
+ }
363
+ ```
364
+
365
+ After:
366
+
367
+ ```ruby
368
+ TurnKit.cost_rates["gpt-5"] = {
369
+ input: 1.25,
370
+ output: 10.0,
371
+ cache_read: 0.125,
372
+ thinking: 10.0
373
+ }
374
+ ```
375
+
376
+ Old aliases such as `cached_input`, `cache_creation`, `reasoning`, and
377
+ `*_per_million` now raise `TurnKit::ConfigError`.
378
+
379
+ ## 14. Update media analysis checks
380
+
381
+ `MediaAnalysisResult#structured?` was removed. Use `data?` instead.
382
+
383
+ ```ruby
384
+ # Before
385
+ analysis.structured?
386
+
387
+ # After
388
+ analysis.data?
389
+ ```
390
+
391
+ ## 15. Non-breaking changes to know about
392
+
393
+ - Turn runtime state such as iteration counts and output-policy audits now lives
394
+ under `options["state"]`. Reads fall back to the older top-level keys, so no
395
+ data migration is required for this change.
396
+ - Compaction examples now use symbol keys, but string keys are still accepted.
397
+ - Built-in memory and Active Record stores already implement atomic turn claims.
398
+ - Generated Rails model/table names still work. Only custom class configuration
399
+ moved to `ActiveRecordStore.new(...)`.
400
+ - Removing the transitive `ruby_llm` dependency does not affect Codex adapter or
401
+ custom-client users.
402
+
403
+ ## 16. Suggested upgrade order
404
+
405
+ 1. Create a branch and update the Gemfile.
406
+ 2. Add `ruby_llm`, if you use the default RubyLLM adapter.
407
+ 3. Update workflows to `Agent.new(orchestrator: true)`.
408
+ 4. Rename `Agent#run`, run accessors, and `TurnKit.default_model` call sites.
409
+ 5. Update custom tools, sub-agents, clients, stores, cost rates, and Rails store
410
+ configuration.
411
+ 6. Add the `output_data` migration if your Rails tables do not have it.
412
+ 7. Run the test suite and at least one real model smoke test for each configured
413
+ adapter.
414
+
415
+ Useful searches:
416
+
417
+ ```sh
418
+ rg "TurnKit::Workflow|TurnKit\.model|run\.output|run\.tool_calls|run\.steps|turnkit_context|structured\?|cached_input|cache_creation|reasoning|system_prompt_contributors|model_prompt_contributors|PromptContribution|conversation_record_class|turn_record_class|message_record_class|tool_execution_record_class"
419
+ ```
420
+
421
+ After the code compiles, run:
422
+
423
+ ```sh
424
+ bundle exec rake test
425
+ ```
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Namespaces: TurnKit:: is the gem's domain layer (agents, conversations,
4
+ # turns). The generated ActiveRecord models in app/models/turnkit live under
5
+ # Turnkit:: and are pure persistence records used by the store below.
6
+ #
7
+ # Pass custom model class names if you moved or renamed the generated models:
8
+ # TurnKit::ActiveRecordStore.new(conversation_class: "My::Conversation", ...)
3
9
  TurnKit.store = TurnKit::ActiveRecordStore.new
4
10
 
5
- TurnKit.conversation_record_class = "Turnkit::Conversation"
6
- TurnKit.turn_record_class = "Turnkit::Turn"
7
- TurnKit.message_record_class = "Turnkit::Message"
8
- TurnKit.tool_execution_record_class = "Turnkit::ToolExecution"
9
-
10
11
  # TurnKit.default_model = "claude-sonnet-4-5"
11
12
  # TurnKit.max_iterations = 25
12
13
  # TurnKit.timeout = 300
@@ -20,6 +21,6 @@ TurnKit.tool_execution_record_class = "Turnkit::ToolExecution"
20
21
  # TurnKit.available_skills = TurnKit::Skill.from_directory(Rails.root.join("app/ai/skills"))
21
22
 
22
23
  # Suggested Rails convention:
23
- # - app/ai/agents/* builds TurnKit::Agent objects for your workflows.
24
+ # - app/ai/agents/* builds TurnKit::Agent objects and orchestrator agents.
24
25
  # - app/ai/tools/* defines TurnKit::Tool subclasses.
25
26
  # - app/ai/skills/* stores reusable Markdown skill files.
@@ -1,7 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
+ # Persists TurnKit records through ActiveRecord model classes generated by
5
+ # `rails g turnkit:install`.
6
+ #
7
+ # Note the namespaces: TurnKit:: is this gem's domain layer; the generated
8
+ # Rails models live under Turnkit:: (Rails-inflection friendly) and are pure
9
+ # persistence records. Turnkit::Conversation (a database row) is not
10
+ # TurnKit::Conversation (the domain object).
4
11
  class ActiveRecordStore < Store
12
+ def initialize(conversation_class: "Turnkit::Conversation", turn_class: "Turnkit::Turn", message_class: "Turnkit::Message", tool_execution_class: "Turnkit::ToolExecution")
13
+ @conversation_class_name = conversation_class
14
+ @turn_class_name = turn_class
15
+ @message_class_name = message_class
16
+ @tool_execution_class_name = tool_execution_class
17
+ end
18
+
5
19
  def create_conversation(attributes)
6
20
  record = conversation_class.create!(record_attributes(attributes, id_key: "uid"))
7
21
  conversation_hash(record)
@@ -145,10 +159,10 @@ module TurnKit
145
159
  end
146
160
 
147
161
  private
148
- def conversation_class = constantize(TurnKit.conversation_record_class || "Turnkit::Conversation")
149
- def turn_class = constantize(TurnKit.turn_record_class || "Turnkit::Turn")
150
- def message_class = constantize(TurnKit.message_record_class || "Turnkit::Message")
151
- def tool_execution_class = constantize(TurnKit.tool_execution_record_class || "Turnkit::ToolExecution")
162
+ def conversation_class = constantize(@conversation_class_name)
163
+ def turn_class = constantize(@turn_class_name)
164
+ def message_class = constantize(@message_class_name)
165
+ def tool_execution_class = constantize(@tool_execution_class_name)
152
166
 
153
167
  def constantize(name)
154
168
  name.to_s.split("::").inject(Object) { |mod, part| mod.const_get(part) }
@@ -22,9 +22,12 @@ module TurnKit
22
22
 
23
23
  def validate!(model:)
24
24
  raise ModelAccessError, "codex command is required" if command.empty?
25
- raise ModelAccessError, "#{command.inspect} was not found. Install OpenAI Codex CLI and run `codex login --device-auth`." unless executable?(command)
26
25
 
27
- stdout, stderr, status = @runner.call([ command, "login", "status" ], stdin_data: nil, chdir: working_directory)
26
+ begin
27
+ stdout, stderr, status = @runner.call([ command, "login", "status" ], stdin_data: nil, chdir: working_directory)
28
+ rescue Errno::ENOENT
29
+ raise ModelAccessError, "#{command.inspect} was not found. Install OpenAI Codex CLI and run `codex login --device-auth`."
30
+ end
28
31
  return true if status.success?
29
32
 
30
33
  message = [ stderr, stdout ].join("\n").strip
@@ -32,12 +35,13 @@ module TurnKit
32
35
  raise ModelAccessError, [ "Codex is not authenticated.", message, hint ].reject(&:empty?).join(" ")
33
36
  end
34
37
 
35
- def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
38
+ def chat(model:, messages:, tools:, instructions:, dynamic_instructions: nil, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
36
39
  raise ToolError, "TurnKit tools are not supported by the Codex adapter; Codex uses its own local tools" if Array(tools).any?
37
40
 
41
+ full_instructions = [ instructions.to_s, dynamic_instructions.to_s ].reject(&:empty?).join("\n\n")
38
42
  with_tempfiles(output_schema: output_schema) do |schema_file, output_file|
39
43
  command = exec_command(model: model, schema_file: schema_file&.path, output_file: output_file.path)
40
- stdout, stderr, status = @runner.call(command, stdin_data: prompt_for(messages: messages, instructions: instructions), chdir: working_directory)
44
+ stdout, stderr, status = @runner.call(command, stdin_data: prompt_for(messages: messages, instructions: full_instructions), chdir: working_directory)
41
45
  emit_codex_events(stdout, on_event: on_event)
42
46
  raise ModelAccessError, stderr.strip.empty? ? "codex exec failed" : stderr.strip unless status.success?
43
47
 
@@ -144,13 +148,6 @@ module TurnKit
144
148
  end
145
149
  end
146
150
 
147
- def executable?(name)
148
- return true if @runner != method(:run_command)
149
- return File.executable?(name) if name.include?(File::SEPARATOR)
150
-
151
- ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |path| File.executable?(File.join(path, name)) }
152
- end
153
-
154
151
  def run_command(command, stdin_data:, chdir:)
155
152
  stdout, stderr, status = Open3.capture3(*command, stdin_data: stdin_data, chdir: chdir)
156
153
  [ stdout, stderr, status ]
@@ -11,8 +11,7 @@ module TurnKit
11
11
  }.freeze
12
12
 
13
13
  def validate!(model:)
14
- require "ruby_llm"
15
-
14
+ ensure_ruby_llm!
16
15
  raise ModelAccessError, "model is required" if model.to_s.empty?
17
16
 
18
17
  configure_from_environment
@@ -24,13 +23,12 @@ module TurnKit
24
23
  raise ModelAccessError, "#{key_name} is required for #{model}. Set ENV[#{key_name.inspect}] or configure RubyLLM before running TurnKit."
25
24
  end
26
25
 
27
- def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
28
- require "ruby_llm"
29
-
26
+ def chat(model:, messages:, tools:, instructions:, dynamic_instructions: nil, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
27
+ ensure_ruby_llm!
30
28
  configure_from_environment
31
29
 
32
30
  chat = ::RubyLLM.chat(model: model)
33
- add_instructions(chat, instructions, model: model)
31
+ add_instructions(chat, instructions, dynamic_instructions, model: model)
34
32
  chat.with_temperature(temperature) if temperature
35
33
  apply_thinking(chat, thinking)
36
34
  chat.with_schema(normalize_schema(output_schema)) if output_schema
@@ -42,10 +40,10 @@ module TurnKit
42
40
  end
43
41
 
44
42
  def paint(prompt:, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: nil, on_event: nil)
45
- require "ruby_llm"
46
-
43
+ ensure_ruby_llm!
47
44
  configure_from_environment
48
- kwargs = paint_kwargs(
45
+ image = ::RubyLLM.paint(
46
+ prompt,
49
47
  model: model,
50
48
  provider: provider,
51
49
  assume_model_exists: assume_model_exists || false,
@@ -54,13 +52,11 @@ module TurnKit
54
52
  mask: mask,
55
53
  params: params || {}
56
54
  )
57
- image = ::RubyLLM.paint(prompt, **kwargs)
58
55
  normalize_image_response(image, model: model, provider: provider, params: { "size" => size || "1024x1024" }.merge(params || {}), metadata: metadata)
59
56
  end
60
57
 
61
58
  def view_media(media:, objective:, model:, provider: nil, output_schema: nil, params: {}, metadata: nil, on_event: nil)
62
- require "ruby_llm"
63
-
59
+ ensure_ruby_llm!
64
60
  configure_from_environment
65
61
  media_input = MediaInput.wrap(media)
66
62
  content = ::RubyLLM::Content.new(objective.to_s)
@@ -76,6 +72,12 @@ module TurnKit
76
72
  end
77
73
 
78
74
  private
75
+ def ensure_ruby_llm!
76
+ require "ruby_llm"
77
+ rescue LoadError
78
+ raise ConfigError, "TurnKit::Adapters::RubyLLM requires the ruby_llm gem (>= 1.16). Add `gem \"ruby_llm\"` to your Gemfile."
79
+ end
80
+
79
81
  def configure_from_environment
80
82
  config = ::RubyLLM.config
81
83
  config.openai_api_key ||= ENV["OPENAI_API_KEY"]
@@ -117,19 +119,17 @@ module TurnKit
117
119
  end
118
120
  end
119
121
 
122
+ # RubyLLM has no public API to request a completion without executing
123
+ # tool calls, so this depends on the private RubyLLM::Chat#provider_completion
124
+ # (added in ruby_llm 1.16). TurnKit must run tools itself (to persist
125
+ # executions and enforce budgets). Guarded by a canary test in the
126
+ # suite; revisit when RubyLLM exposes a public equivalent.
120
127
  def complete_without_tool_execution(chat)
121
- provider = chat.instance_variable_get(:@provider)
122
- provider.complete(
123
- chat.messages,
124
- tools: chat.tools,
125
- tool_prefs: chat.tool_prefs,
126
- temperature: chat.instance_variable_get(:@temperature),
127
- model: chat.model,
128
- params: chat.params,
129
- headers: chat.headers,
130
- schema: chat.schema,
131
- thinking: chat.instance_variable_get(:@thinking)
132
- )
128
+ unless chat.respond_to?(:provider_completion, true)
129
+ raise ConfigError, "TurnKit::Adapters::RubyLLM requires ruby_llm >= 1.16 (RubyLLM::Chat#provider_completion not found)"
130
+ end
131
+
132
+ chat.send(:provider_completion)
133
133
  end
134
134
 
135
135
  def add_message(chat, message)
@@ -145,15 +145,16 @@ module TurnKit
145
145
  )
146
146
  end
147
147
 
148
- def add_instructions(chat, instructions, model:)
149
- return if instructions.nil? || instructions.empty?
148
+ def add_instructions(chat, instructions, dynamic_instructions, model:)
149
+ stable = instructions.to_s
150
+ dynamic = dynamic_instructions.to_s
151
+ return if stable.empty? && dynamic.empty?
150
152
 
151
- if prompt_cache_enabled? && anthropic_model?(model) && instructions.include?(SystemPrompt::CACHE_BOUNDARY)
152
- stable, dynamic = SystemPrompt.split_cache_boundary(instructions)
153
+ if prompt_cache_enabled? && anthropic_model?(model) && !dynamic.empty?
153
154
  add_system_message(chat, stable, cache: true)
154
155
  add_system_message(chat, dynamic, cache: false)
155
156
  else
156
- chat.with_instructions(instructions)
157
+ chat.with_instructions([ stable, dynamic ].reject(&:empty?).join("\n\n"))
157
158
  end
158
159
  end
159
160
 
@@ -189,8 +190,6 @@ module TurnKit
189
190
  end
190
191
 
191
192
  def ruby_llm_tool(tool)
192
- require "ruby_llm"
193
-
194
193
  Class.new(::RubyLLM::Tool) do
195
194
  define_singleton_method(:name) { tool.tool_name }
196
195
  description tool.description
@@ -281,21 +280,6 @@ module TurnKit
281
280
  response.cost&.total
282
281
  end
283
282
 
284
- def paint_kwargs(kwargs)
285
- parameters = ::RubyLLM::Image.method(:paint).parameters
286
- return kwargs if parameters.any? { |kind, _| kind == :keyrest }
287
-
288
- accepted = parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) }
289
- unsupported = kwargs.keys.select { |key| !accepted.include?(key) && !blank?(kwargs[key]) }
290
- raise ArgumentError, "RubyLLM image generation does not support: #{unsupported.join(", ")}" if unsupported.any?
291
-
292
- kwargs.slice(*accepted)
293
- end
294
-
295
- def blank?(value)
296
- value.nil? || value == false || (value.respond_to?(:empty?) && value.empty?)
297
- end
298
-
299
283
  def normalize_image_response(image, model:, provider:, params:, metadata:)
300
284
  usage = Usage.new(
301
285
  input_tokens: image_usage_value(image, "input_tokens"),