turnkit 0.2.6 → 0.2.7

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.
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-red.svg)](https://www.ruby-lang.org)
5
5
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE.md)
6
6
 
7
- Build durable Ruby AI agents with turns, tools, skills, and Rails persistence.
7
+ Build durable Ruby and Rails agents with tools, skills, sub-agents, and persistence.
8
8
 
9
9
  ## Installation
10
10
 
@@ -22,21 +22,12 @@ bundle install
22
22
 
23
23
  ## Quick Start
24
24
 
25
- Set a provider key. TurnKit uses RubyLLM under the hood and defaults to Anthropic Claude:
25
+ Set an API key:
26
26
 
27
27
  ```sh
28
28
  export ANTHROPIC_API_KEY=...
29
29
  ```
30
30
 
31
- | Provider | Env var | Example model |
32
- | --- | --- | --- |
33
- | Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-5` |
34
- | OpenAI | `OPENAI_API_KEY` | `gpt-4.1-mini` |
35
- | Gemini | `GEMINI_API_KEY` | `gemini-2.5-flash` |
36
-
37
- > [!WARNING]
38
- > TurnKit defaults to `claude-sonnet-4-5`. If `ANTHROPIC_API_KEY` is unset or blank, set `TurnKit.default_model` to a provider you have configured.
39
-
40
31
  Create an agent:
41
32
 
42
33
  ```ruby
@@ -59,69 +50,28 @@ puts turn.output_text
59
50
 
60
51
  ### Models
61
52
 
62
- Set the default model:
63
-
64
- ```ruby
65
- TurnKit.default_model = "claude-sonnet-4-5"
66
- ```
67
-
68
- Use OpenAI:
69
-
70
- ```sh
71
- export OPENAI_API_KEY=...
72
- ```
73
-
74
- Set an OpenAI model:
53
+ Set a model:
75
54
 
76
55
  ```ruby
77
56
  TurnKit.default_model = "gpt-4.1-mini"
78
57
  ```
79
58
 
80
- Use Gemini:
59
+ Set the matching key:
81
60
 
82
61
  ```sh
83
- export GEMINI_API_KEY=...
84
- ```
85
-
86
- Set a Gemini model:
87
-
88
- ```ruby
89
- TurnKit.default_model = "gemini-2.5-flash"
90
- ```
91
-
92
- ### Thinking
93
-
94
- Enable provider reasoning or extended thinking per agent:
95
-
96
- ```ruby
97
- agent = TurnKit::Agent.new(
98
- name: "reasoner",
99
- model: "claude-sonnet-4-5",
100
- thinking: { budget: 4_000 }
101
- )
102
- ```
103
-
104
- Use effort-based thinking for providers that support it:
105
-
106
- ```ruby
107
- agent = TurnKit::Agent.new(
108
- name: "reasoner",
109
- model: "gemini-2.5-flash",
110
- thinking: { effort: :high }
111
- )
62
+ export OPENAI_API_KEY=...
112
63
  ```
113
64
 
114
- Override or disable thinking for one turn:
115
-
116
- ```ruby
117
- conversation = agent.conversation
118
- conversation.ask("Solve this carefully.", thinking: { budget: 8_000 })
119
- conversation.ask("Answer quickly.", thinking: nil)
120
- ```
65
+ Use these common providers:
121
66
 
122
- TurnKit passes `thinking` to RubyLLM as `{ effort:, budget: }`. Anthropic requires `budget`; Gemini and OpenRouter can use `effort`, `budget`, or both depending on the model.
67
+ | Provider | Key | Model |
68
+ | --- | --- | --- |
69
+ | Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-5` |
70
+ | OpenAI | `OPENAI_API_KEY` | `gpt-4.1-mini` |
71
+ | Gemini | `GEMINI_API_KEY` | `gemini-2.5-flash` |
72
+ | OpenRouter | `OPENROUTER_API_KEY` | `openrouter/...` |
123
73
 
124
- When the provider reports reasoning usage, TurnKit records it as `thinking_tokens` and includes it in usage totals and cost calculation.
74
+ Expect `TurnKit::ModelAccessError` for obvious key mistakes.
125
75
 
126
76
  ### Conversations
127
77
 
@@ -132,12 +82,13 @@ agent = TurnKit::Agent.new(
132
82
  name: "writer",
133
83
  instructions: "Write clear release notes."
134
84
  )
85
+
86
+ conversation = agent.conversation(subject: "v1 launch")
135
87
  ```
136
88
 
137
89
  Add context:
138
90
 
139
91
  ```ruby
140
- conversation = agent.conversation(subject: "v1 launch")
141
92
  conversation.say("Mention faster tool execution.")
142
93
  ```
143
94
 
@@ -148,91 +99,29 @@ turn = conversation.run!
148
99
  puts turn.output_text
149
100
  ```
150
101
 
151
- ### Context compaction
102
+ ### Prompt Preview
152
103
 
153
- TurnKit automatically compacts long conversations. Older messages are summarized for future model calls, while the original transcript remains stored durably.
104
+ Preview a pending turn:
154
105
 
155
106
  ```ruby
156
- conversation = agent.conversation
157
- conversation.ask("Work through this long task.")
107
+ turn = conversation.ask("Draft the launch email.", async: true)
108
+ request = turn.preview
158
109
  ```
159
110
 
160
- By default, compaction is enabled and uses the current turn model for the summary call. If a turn runs with `gpt-5`, compaction uses `gpt-5` unless you configure a separate summary model.
161
-
162
- Disable compaction globally:
111
+ Inspect the request:
163
112
 
164
113
  ```ruby
165
- TurnKit.compaction = false
114
+ request.model
115
+ request.messages
116
+ request.tool_names
117
+ request.instructions
118
+ request.report
166
119
  ```
167
120
 
168
- Use a different model for summaries:
121
+ Run the reviewed turn:
169
122
 
170
123
  ```ruby
171
- TurnKit.compaction = {
172
- model: "gpt-4.1-mini"
173
- }
174
- ```
175
-
176
- You can also configure the compaction threshold and estimated context limit:
177
-
178
- ```ruby
179
- TurnKit.compaction = {
180
- model: "gpt-4.1-mini",
181
- threshold: 0.75,
182
- context_limit: 128_000
183
- }
184
- ```
185
-
186
- Configure compaction for one agent:
187
-
188
- ```ruby
189
- agent = TurnKit::Agent.new(
190
- name: "engineer",
191
- model: "gpt-5",
192
- compaction: {
193
- model: "gpt-4.1-mini",
194
- threshold: 0.75,
195
- context_limit: 128_000
196
- }
197
- )
198
- ```
199
-
200
- In this example, normal turns use `gpt-5` and compaction summaries use `gpt-4.1-mini`.
201
-
202
- Override the model for one manual compaction:
203
-
204
- ```ruby
205
- conversation.compact!(model: "gpt-4.1-mini")
206
- conversation.compact!(focus: "billing migration", model: "gpt-4.1-mini")
207
- ```
208
-
209
- Disable compaction for a single turn:
210
-
211
- ```ruby
212
- conversation.ask("Continue", compact: false)
213
- ```
214
-
215
- Manually compact a conversation:
216
-
217
- ```ruby
218
- conversation.compact!
219
- conversation.compact!(focus: "billing migration")
220
- ```
221
-
222
- Compaction is append-only: TurnKit stores a `context_summary` message with metadata describing the message range it replaces for model projection. The original messages are not deleted, so `conversation.messages` remains the full durable transcript. Future model calls see a compacted projection that includes a reference-only summary and the recent tail.
223
-
224
- The model-visible projection uses a synthetic summary exchange followed by recent messages:
225
-
226
- ```text
227
- user: What did we do so far?
228
- assistant: [CONTEXT COMPACTION — REFERENCE ONLY] ...
229
- user: latest request
230
- ```
231
-
232
- For a local smoke test without calling a real provider, run:
233
-
234
- ```sh
235
- ruby script/manual_compaction.rb
124
+ turn.run!
236
125
  ```
237
126
 
238
127
  ### Tools
@@ -243,11 +132,15 @@ Create a tool:
243
132
  class SaveReport < TurnKit::Tool
244
133
  description "Save a report."
245
134
  usage_hint "Use when the user asks to persist a report."
135
+
246
136
  parameter :title, :string, required: true
247
137
  parameter :body, :string, required: true
248
138
 
249
139
  def self.ends_turn? = true
250
- def self.completion_message(result) = "Saved #{result.fetch("report_id")}."
140
+
141
+ def self.completion_message(result)
142
+ "Saved #{result.fetch("report_id")}."
143
+ end
251
144
 
252
145
  def call(title:, body:, context:)
253
146
  { report_id: "rep_1", title: title, body: body }
@@ -255,7 +148,7 @@ class SaveReport < TurnKit::Tool
255
148
  end
256
149
  ```
257
150
 
258
- Use the tool:
151
+ Register the tool:
259
152
 
260
153
  ```ruby
261
154
  agent = TurnKit::Agent.new(
@@ -265,82 +158,87 @@ agent = TurnKit::Agent.new(
265
158
  )
266
159
  ```
267
160
 
268
- Ask for tool use:
161
+ Run the tool loop:
269
162
 
270
163
  ```ruby
271
164
  turn = agent.conversation.ask("Save a short status report.")
272
165
  puts turn.output_text
273
166
  ```
274
167
 
275
- #### Defining application tools
168
+ Rely on TurnKit to validate tools and model-provided arguments.
169
+
170
+ ### Structured Output
276
171
 
277
- Tools are classes, not instances. Namespaced tools work fine, and the default tool name comes from the class name: `Assistant::Tools::WebSearch` becomes `web_search`.
172
+ Define a schema:
278
173
 
279
174
  ```ruby
280
- module Assistant
281
- module Tools
282
- class WebSearch < TurnKit::Tool
283
- description "Search the web for current information."
284
- usage_hint "Use when current external information is needed."
175
+ schema = {
176
+ type: "object",
177
+ properties: {
178
+ title: { type: "string" },
179
+ bullets: {
180
+ type: "array",
181
+ items: { type: "string" }
182
+ }
183
+ },
184
+ required: ["title", "bullets"]
185
+ }
186
+ ```
285
187
 
286
- parameter :objective, :string, required: true
287
- parameter :search_queries, :array, required: false
188
+ Use structured output:
288
189
 
289
- def call(objective:, search_queries: nil, context:)
290
- ParallelClient.new.web_search(
291
- objective: objective,
292
- search_queries: search_queries
293
- )
294
- end
295
- end
296
- end
297
- end
190
+ ```ruby
191
+ agent = TurnKit::Agent.new(
192
+ name: "writer",
193
+ output_schema: schema
194
+ )
195
+
196
+ turn = agent.conversation.ask("Summarize the launch plan.")
197
+ puts turn.output_data
298
198
  ```
299
199
 
300
- Register tool classes on the agent:
200
+ Override the schema per turn:
301
201
 
302
202
  ```ruby
303
- agent = TurnKit::Agent.new(
304
- name: "researcher",
305
- tools: [
306
- Assistant::Tools::WebSearch,
307
- Assistant::Tools::ReadWebPage
308
- ]
203
+ conversation.ask(
204
+ "Return one decision.",
205
+ output_schema: {
206
+ type: "object",
207
+ properties: {
208
+ decision: { type: "string" }
209
+ }
210
+ }
309
211
  )
310
212
  ```
311
213
 
312
- #### Tool context
214
+ ### Events
313
215
 
314
- Every tool receives a `context:` object. Use it for logging, correlation, persistence, and domain scoping:
216
+ Subscribe globally:
315
217
 
316
218
  ```ruby
317
- def call(query:, context:)
318
- context.turn # The TurnKit::Turn being run
319
- context.execution # The TurnKit::ToolExecution for this tool call
320
-
321
- { query: query }
219
+ TurnKit.on_event = ->(event) do
220
+ Rails.logger.info("turnkit.#{event.type}")
322
221
  end
323
222
  ```
324
223
 
325
- If your application already uses a `context:` keyword for something else, use `turnkit_context:` instead:
224
+ Subscribe per agent:
326
225
 
327
226
  ```ruby
328
- def call(query:, turnkit_context:)
329
- { turn_id: turnkit_context.turn.id, query: query }
330
- end
227
+ agent = TurnKit::Agent.new(
228
+ name: "helper",
229
+ on_event: ->(event) { puts event.type }
230
+ )
331
231
  ```
332
232
 
333
- #### Tool return values
233
+ Subscribe per turn:
334
234
 
335
- Prefer returning a `Hash`. TurnKit serializes the normalized value as the tool result:
336
-
337
- | Return value | Stored tool result |
338
- | --- | --- |
339
- | `Hash` | Keys are stringified. |
340
- | `Array` | Wrapped as `{ "items" => [...] }`. |
341
- | Scalar | Wrapped as `{ "result" => value.to_s }`. |
235
+ ```ruby
236
+ turn.run! do |event|
237
+ puts event.type
238
+ end
239
+ ```
342
240
 
343
- Avoid returning arbitrary objects unless you convert them to a plain Hash or Array first.
241
+ Use events for turns, model calls, messages, and tool calls.
344
242
 
345
243
  ### Skills
346
244
 
@@ -370,7 +268,7 @@ writer = TurnKit::Agent.new(
370
268
  )
371
269
  ```
372
270
 
373
- Delegate to it:
271
+ Register the sub-agent:
374
272
 
375
273
  ```ruby
376
274
  editor = TurnKit::Agent.new(
@@ -386,117 +284,36 @@ turn = editor.conversation.ask("Ask the writer for three headlines.")
386
284
  puts turn.output_text
387
285
  ```
388
286
 
389
- ### Usage and costs
287
+ Use sub-agents for isolated child conversations.
390
288
 
391
- Inspect token usage:
289
+ ### Context Compaction
392
290
 
393
- ```ruby
394
- turn.usage.total_tokens
395
- conversation.usage.total_tokens
396
- agent.usage.total_tokens
397
- ```
398
-
399
- Inspect costs:
291
+ Disable compaction:
400
292
 
401
293
  ```ruby
402
- turn.cost.total
403
- conversation.cost.total
404
- agent.cost.total
294
+ TurnKit.compaction = false
405
295
  ```
406
296
 
407
- Use RubyLLM registry prices by default.
408
-
409
- Override model rates:
297
+ Configure compaction:
410
298
 
411
299
  ```ruby
412
- TurnKit.cost_rates = {
413
- "my-model" => {
414
- input: 0.25,
415
- output: 1.00,
416
- cached_input: 0.05,
417
- cache_creation: 0.25
418
- }
300
+ TurnKit.compaction = {
301
+ model: "gpt-4.1-mini",
302
+ threshold: 0.75,
303
+ context_limit: 128_000
419
304
  }
420
305
  ```
421
306
 
422
- Override cost calculation:
423
-
424
- ```ruby
425
- TurnKit.cost_calculator = ->(usage, model) do
426
- {
427
- input: usage.input_tokens * 0.25 / 1_000_000.0,
428
- output: usage.output_tokens * 1.00 / 1_000_000.0
429
- }
430
- end
431
- ```
432
-
433
- Limit turn cost:
307
+ Compact manually:
434
308
 
435
309
  ```ruby
436
- agent = TurnKit::Agent.new(
437
- name: "analyst",
438
- cost_limit: 0.25
439
- )
440
- ```
441
-
442
- ### Prompt caching
443
-
444
- Enable prompt caching:
445
-
446
- ```ruby
447
- TurnKit.prompt_cache = :auto
448
- ```
449
-
450
- Disable prompt caching:
451
-
452
- ```ruby
453
- TurnKit.prompt_cache = :off
454
- ```
455
-
456
- Split custom prompts:
457
-
458
- ```ruby
459
- agent = TurnKit::Agent.new(
460
- name: "cached",
461
- system_prompt: [
462
- "Stable instructions and tool guidance.",
463
- TurnKit::SystemPrompt::CACHE_BOUNDARY,
464
- "Dynamic subject and live context."
465
- ].join("\n")
466
- )
467
- ```
468
-
469
- ### Custom clients
470
-
471
- Create a client:
472
-
473
- ```ruby
474
- class MyClient < TurnKit::Client
475
- def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, metadata: nil)
476
- TurnKit::Result.new(
477
- text: "provider response",
478
- model: model,
479
- usage: TurnKit::Usage.new(
480
- input_tokens: 100,
481
- output_tokens: 20,
482
- cached_tokens: 80,
483
- cache_write_tokens: 100
484
- )
485
- )
486
- end
487
- end
488
- ```
489
-
490
- Use the client:
491
-
492
- ```ruby
493
- TurnKit.client = MyClient.new
310
+ conversation.compact!(focus: "billing migration")
494
311
  ```
495
312
 
496
- Split cache sections:
313
+ Run the local smoke test:
497
314
 
498
- ```ruby
499
- stable, dynamic = TurnKit::SystemPrompt.split_cache_boundary(instructions)
315
+ ```sh
316
+ ruby script/manual_compaction.rb
500
317
  ```
501
318
 
502
319
  ### Rails
@@ -507,47 +324,18 @@ Install Rails persistence:
507
324
  bin/rails generate turnkit:install
508
325
  ```
509
326
 
510
- The installer creates:
511
-
512
- - `config/initializers/turnkit.rb`
513
- - `app/models/turnkit/conversation.rb`
514
- - `app/models/turnkit/turn.rb`
515
- - `app/models/turnkit/message.rb`
516
- - `app/models/turnkit/tool_execution.rb`
517
- - a migration for TurnKit persistence
518
-
519
- The generated migration currently uses `ActiveRecord::Migration[7.1]`. In a newer Rails app, update that version if your app requires it, for example `ActiveRecord::Migration[8.1]`.
520
-
521
327
  Run migrations:
522
328
 
523
329
  ```sh
524
330
  bin/rails db:migrate
525
331
  ```
526
332
 
527
- Configure Rails:
528
-
529
- ```ruby
530
- TurnKit.store = TurnKit::ActiveRecordStore.new
531
- ```
532
-
533
- Suggested Rails file layout for your application AI code:
534
-
535
- ```text
536
- app/models/assistant/
537
- tools/
538
- web_search.rb
539
- read_web_page.rb
540
- skills/
541
- prompts/
542
- ```
543
-
544
- If you prefer to keep AI infrastructure out of `app/models`, add an autoloaded directory such as:
333
+ Use this layout:
545
334
 
546
335
  ```text
547
- app/ai/
548
- tools/
549
- skills/
550
- prompts/
336
+ app/ai/agents/
337
+ app/ai/tools/
338
+ app/ai/skills/
551
339
  ```
552
340
 
553
341
  Reconcile stale turns:
@@ -556,114 +344,63 @@ Reconcile stale turns:
556
344
  TurnKit.reconcile_stale!
557
345
  ```
558
346
 
559
- #### Debugging Rails persistence
560
-
561
- Inspect the latest persisted turn in a Rails console:
347
+ ## Options
562
348
 
563
- ```ruby
564
- turn = Turnkit::Turn.order(created_at: :desc).first
565
- turn.status
566
- turn.error
567
- turn.output_text
568
- ```
349
+ | Option | Description |
350
+ | --- | --- |
351
+ | `TurnKit.default_model` | Set the default model. |
352
+ | `TurnKit.client` | Set the model client. |
353
+ | `TurnKit.store` | Set the persistence store. |
354
+ | `TurnKit.max_iterations` | Limit model loop iterations. |
355
+ | `TurnKit.max_depth` | Limit sub-agent depth. |
356
+ | `TurnKit.max_tool_executions` | Limit tool calls per turn. |
357
+ | `TurnKit.timeout` | Limit turn runtime. |
358
+ | `TurnKit.cost_limit` | Limit estimated turn cost. |
359
+ | `TurnKit.compaction` | Configure context compaction. |
360
+ | `TurnKit.on_event` | Subscribe to lifecycle events. |
569
361
 
570
- Check whether the model actually called tools:
362
+ Set options globally:
571
363
 
572
364
  ```ruby
573
- Turnkit::ToolExecution
574
- .where(turn_uid: turn.uid)
575
- .order(:created_at)
576
- .map { |execution|
577
- {
578
- name: execution.tool_name,
579
- status: execution.status,
580
- arguments: execution.arguments,
581
- result_keys: execution.result&.keys,
582
- error: execution.error
583
- }
584
- }
365
+ TurnKit.default_model = "gpt-4.1-mini"
366
+ TurnKit.max_iterations = 25
367
+ TurnKit.timeout = 300
585
368
  ```
586
369
 
587
- #### Live smoke test
588
-
589
- Use a model whose provider key is configured, then run a real tool-using turn:
370
+ Set options per agent:
590
371
 
591
372
  ```ruby
592
- TurnKit.default_model = "gpt-4.1-mini"
593
-
594
373
  agent = TurnKit::Agent.new(
595
- name: "researcher",
596
- instructions: "Use web_search, then read_web_page, before answering.",
597
- tools: [
598
- Assistant::Tools::WebSearch,
599
- Assistant::Tools::ReadWebPage
600
- ]
601
- )
602
-
603
- turn = agent.conversation.ask(
604
- "Search for the TurnKit Ruby gem, read the first useful result, then summarize it."
374
+ name: "engineer",
375
+ model: "gpt-4.1-mini",
376
+ max_iterations: 10,
377
+ max_depth: 2
605
378
  )
606
-
607
- puts turn.output_text
608
-
609
- pp Turnkit::ToolExecution
610
- .where(turn_uid: turn.id)
611
- .order(:created_at)
612
- .pluck(:tool_name, :status, :error)
613
379
  ```
614
380
 
615
- ## Options
616
-
617
- Configure defaults:
381
+ Enable thinking:
618
382
 
619
383
  ```ruby
620
- TurnKit.default_model = "claude-sonnet-4-5"
621
- TurnKit.max_iterations = 25
622
- TurnKit.timeout = 300
623
- TurnKit.max_depth = 3
624
- TurnKit.max_tool_executions = 100
625
- TurnKit.cost_limit = nil
626
- TurnKit.cost_rates = {}
627
- TurnKit.cost_calculator = nil
628
- TurnKit.prompt_cache = :auto
629
- TurnKit.compaction = true
384
+ agent = TurnKit::Agent.new(
385
+ name: "reasoner",
386
+ model: "claude-sonnet-4-5",
387
+ thinking: { budget: 4_000 }
388
+ )
630
389
  ```
631
390
 
632
- Override an agent:
391
+ ## Upgrading
392
+
393
+ Add `output_data` for structured output persistence.
633
394
 
634
395
  ```ruby
635
- agent = TurnKit::Agent.new(
636
- name: "analyst",
637
- model: "gpt-4.1-mini",
638
- max_iterations: 10,
639
- timeout: 60,
640
- cost_limit: 0.25,
641
- thinking: { effort: :low }
642
- )
396
+ add_column :turnkit_turns, :output_data, :json
643
397
  ```
644
398
 
645
- | Option | Description |
646
- | --- | --- |
647
- | `default_model` | Set the default RubyLLM model. |
648
- | `client` | Set the model client. |
649
- | `store` | Set the conversation store. |
650
- | `max_iterations` | Limit model calls per turn. |
651
- | `timeout` | Limit seconds per root turn. |
652
- | `max_tool_executions` | Limit tool calls per root turn. |
653
- | `cost_limit` | Limit cost per root turn. |
654
- | `thinking` | Configure provider reasoning or extended thinking per agent. |
655
- | `cost_rates` | Override prices by model. |
656
- | `cost_calculator` | Override cost calculation. |
657
- | `prompt_cache` | Use provider prompt caching. |
658
- | `compaction` | Enable, disable, or configure automatic context compaction. |
399
+ Skip this step for new installs.
659
400
 
660
401
  ## Contributing
661
402
 
662
- Report bugs and open pull requests on GitHub:
663
-
664
- ```text
665
- https://github.com/samuelcouch/turnkit
666
- ```
403
+ Fork the project.
667
404
 
668
405
  Run tests:
669
406
 
@@ -671,6 +408,14 @@ Run tests:
671
408
  bundle exec rake test
672
409
  ```
673
410
 
411
+ Run syntax checks:
412
+
413
+ ```sh
414
+ find lib test examples -type f -name '*.rb' -print0 | xargs -0 ruby -c
415
+ ```
416
+
417
+ Open a pull request.
418
+
674
419
  ## License
675
420
 
676
- See the MIT License.
421
+ Use this gem under the MIT License.