turnkit 0.2.5 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 271ce272a71a97aa2991a580f36205e4cef8e19466e2e480b0ac6f0f0225d51f
4
- data.tar.gz: b9a0503f499d3eb850e7eece6f508b6fbc206d6398263f6005520b7ef716493b
3
+ metadata.gz: 1c205e6b6c72785350419adfb6515f9b2d213c3eef06c9516a038667bae24842
4
+ data.tar.gz: 74c72004e3334cafa69071034aaa98c14f98262fe56bee9a69ac058bd70499af
5
5
  SHA512:
6
- metadata.gz: f8772f25a95c44b2ba3d1a17a3e89d0ba142d862e798cee6daef9c54e04deaa3d8dee77deae48b5a77f7b6051b467a14c355aabf5115b1ce89832a27c87eb1b6
7
- data.tar.gz: 9b12cccaa55c8d791168eca90655e3b9db89409b69fe59f8b45d23bef71aeec296c538696af44e484da9884dfde4ace67bbfd81d4a6647783f1f7f299ef0e485
6
+ metadata.gz: 6df2331b9e594e1c4925113fb39996ace94860181037397e67855afebf479cb128ad83cbc2d76dcb8c2fe85d55ca042624d3f5b5ff3b33ba7cd7b4fdf1dbf62c
7
+ data.tar.gz: 640c1fdfdbdb08610ba75885e8fb6903c81ecfd90dec5dcf2eeb4462e13ab17de357af6adcc1e5cf18c9fb4622d769151382278481a7b3d178462b80e2e1bfc2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.6 - 2026-06-07
4
+
5
+ - Add automatic context compaction for long conversations. TurnKit now stores append-only `context_summary` messages and projects compacted history into future model calls while keeping the full transcript durable.
6
+
3
7
  ## 0.2.5 - 2026-06-06
4
8
 
5
9
  - Add per-agent and per-turn provider thinking configuration.
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,93 +50,78 @@ puts turn.output_text
59
50
 
60
51
  ### Models
61
52
 
62
- Set the default model:
53
+ Set a model:
63
54
 
64
55
  ```ruby
65
- TurnKit.default_model = "claude-sonnet-4-5"
56
+ TurnKit.default_model = "gpt-4.1-mini"
66
57
  ```
67
58
 
68
- Use OpenAI:
59
+ Set the matching key:
69
60
 
70
61
  ```sh
71
62
  export OPENAI_API_KEY=...
72
63
  ```
73
64
 
74
- Set an OpenAI model:
75
-
76
- ```ruby
77
- TurnKit.default_model = "gpt-4.1-mini"
78
- ```
79
-
80
- Use Gemini:
65
+ Use these common providers:
81
66
 
82
- ```sh
83
- export GEMINI_API_KEY=...
84
- ```
85
-
86
- Set a Gemini 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/...` |
87
73
 
88
- ```ruby
89
- TurnKit.default_model = "gemini-2.5-flash"
90
- ```
74
+ Expect `TurnKit::ModelAccessError` for obvious key mistakes.
91
75
 
92
- ### Thinking
76
+ ### Conversations
93
77
 
94
- Enable provider reasoning or extended thinking per agent:
78
+ Create a conversation:
95
79
 
96
80
  ```ruby
97
81
  agent = TurnKit::Agent.new(
98
- name: "reasoner",
99
- model: "claude-sonnet-4-5",
100
- thinking: { budget: 4_000 }
82
+ name: "writer",
83
+ instructions: "Write clear release notes."
101
84
  )
85
+
86
+ conversation = agent.conversation(subject: "v1 launch")
102
87
  ```
103
88
 
104
- Use effort-based thinking for providers that support it:
89
+ Add context:
105
90
 
106
91
  ```ruby
107
- agent = TurnKit::Agent.new(
108
- name: "reasoner",
109
- model: "gemini-2.5-flash",
110
- thinking: { effort: :high }
111
- )
92
+ conversation.say("Mention faster tool execution.")
112
93
  ```
113
94
 
114
- Override or disable thinking for one turn:
95
+ Run the agent:
115
96
 
116
97
  ```ruby
117
- conversation = agent.conversation
118
- conversation.ask("Solve this carefully.", thinking: { budget: 8_000 })
119
- conversation.ask("Answer quickly.", thinking: nil)
98
+ turn = conversation.run!
99
+ puts turn.output_text
120
100
  ```
121
101
 
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.
123
-
124
- When the provider reports reasoning usage, TurnKit records it as `thinking_tokens` and includes it in usage totals and cost calculation.
102
+ ### Prompt Preview
125
103
 
126
- ### Conversations
127
-
128
- Create a conversation:
104
+ Preview a pending turn:
129
105
 
130
106
  ```ruby
131
- agent = TurnKit::Agent.new(
132
- name: "writer",
133
- instructions: "Write clear release notes."
134
- )
107
+ turn = conversation.ask("Draft the launch email.", async: true)
108
+ request = turn.preview
135
109
  ```
136
110
 
137
- Add context:
111
+ Inspect the request:
138
112
 
139
113
  ```ruby
140
- conversation = agent.conversation(subject: "v1 launch")
141
- conversation.say("Mention faster tool execution.")
114
+ request.model
115
+ request.messages
116
+ request.tool_names
117
+ request.instructions
118
+ request.report
142
119
  ```
143
120
 
144
- Run the agent:
121
+ Run the reviewed turn:
145
122
 
146
123
  ```ruby
147
- turn = conversation.run!
148
- puts turn.output_text
124
+ turn.run!
149
125
  ```
150
126
 
151
127
  ### Tools
@@ -156,11 +132,15 @@ Create a tool:
156
132
  class SaveReport < TurnKit::Tool
157
133
  description "Save a report."
158
134
  usage_hint "Use when the user asks to persist a report."
135
+
159
136
  parameter :title, :string, required: true
160
137
  parameter :body, :string, required: true
161
138
 
162
139
  def self.ends_turn? = true
163
- 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
164
144
 
165
145
  def call(title:, body:, context:)
166
146
  { report_id: "rep_1", title: title, body: body }
@@ -168,7 +148,7 @@ class SaveReport < TurnKit::Tool
168
148
  end
169
149
  ```
170
150
 
171
- Use the tool:
151
+ Register the tool:
172
152
 
173
153
  ```ruby
174
154
  agent = TurnKit::Agent.new(
@@ -178,82 +158,87 @@ agent = TurnKit::Agent.new(
178
158
  )
179
159
  ```
180
160
 
181
- Ask for tool use:
161
+ Run the tool loop:
182
162
 
183
163
  ```ruby
184
164
  turn = agent.conversation.ask("Save a short status report.")
185
165
  puts turn.output_text
186
166
  ```
187
167
 
188
- #### Defining application tools
168
+ Rely on TurnKit to validate tools and model-provided arguments.
169
+
170
+ ### Structured Output
189
171
 
190
- 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:
191
173
 
192
174
  ```ruby
193
- module Assistant
194
- module Tools
195
- class WebSearch < TurnKit::Tool
196
- description "Search the web for current information."
197
- 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
+ ```
198
187
 
199
- parameter :objective, :string, required: true
200
- parameter :search_queries, :array, required: false
188
+ Use structured output:
201
189
 
202
- def call(objective:, search_queries: nil, context:)
203
- ParallelClient.new.web_search(
204
- objective: objective,
205
- search_queries: search_queries
206
- )
207
- end
208
- end
209
- end
210
- 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
211
198
  ```
212
199
 
213
- Register tool classes on the agent:
200
+ Override the schema per turn:
214
201
 
215
202
  ```ruby
216
- agent = TurnKit::Agent.new(
217
- name: "researcher",
218
- tools: [
219
- Assistant::Tools::WebSearch,
220
- Assistant::Tools::ReadWebPage
221
- ]
203
+ conversation.ask(
204
+ "Return one decision.",
205
+ output_schema: {
206
+ type: "object",
207
+ properties: {
208
+ decision: { type: "string" }
209
+ }
210
+ }
222
211
  )
223
212
  ```
224
213
 
225
- #### Tool context
214
+ ### Events
226
215
 
227
- Every tool receives a `context:` object. Use it for logging, correlation, persistence, and domain scoping:
216
+ Subscribe globally:
228
217
 
229
218
  ```ruby
230
- def call(query:, context:)
231
- context.turn # The TurnKit::Turn being run
232
- context.execution # The TurnKit::ToolExecution for this tool call
233
-
234
- { query: query }
219
+ TurnKit.on_event = ->(event) do
220
+ Rails.logger.info("turnkit.#{event.type}")
235
221
  end
236
222
  ```
237
223
 
238
- If your application already uses a `context:` keyword for something else, use `turnkit_context:` instead:
224
+ Subscribe per agent:
239
225
 
240
226
  ```ruby
241
- def call(query:, turnkit_context:)
242
- { turn_id: turnkit_context.turn.id, query: query }
243
- end
227
+ agent = TurnKit::Agent.new(
228
+ name: "helper",
229
+ on_event: ->(event) { puts event.type }
230
+ )
244
231
  ```
245
232
 
246
- #### Tool return values
247
-
248
- Prefer returning a `Hash`. TurnKit serializes the normalized value as the tool result:
233
+ Subscribe per turn:
249
234
 
250
- | Return value | Stored tool result |
251
- | --- | --- |
252
- | `Hash` | Keys are stringified. |
253
- | `Array` | Wrapped as `{ "items" => [...] }`. |
254
- | Scalar | Wrapped as `{ "result" => value.to_s }`. |
235
+ ```ruby
236
+ turn.run! do |event|
237
+ puts event.type
238
+ end
239
+ ```
255
240
 
256
- 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.
257
242
 
258
243
  ### Skills
259
244
 
@@ -283,7 +268,7 @@ writer = TurnKit::Agent.new(
283
268
  )
284
269
  ```
285
270
 
286
- Delegate to it:
271
+ Register the sub-agent:
287
272
 
288
273
  ```ruby
289
274
  editor = TurnKit::Agent.new(
@@ -299,117 +284,36 @@ turn = editor.conversation.ask("Ask the writer for three headlines.")
299
284
  puts turn.output_text
300
285
  ```
301
286
 
302
- ### Usage and costs
303
-
304
- Inspect token usage:
287
+ Use sub-agents for isolated child conversations.
305
288
 
306
- ```ruby
307
- turn.usage.total_tokens
308
- conversation.usage.total_tokens
309
- agent.usage.total_tokens
310
- ```
289
+ ### Context Compaction
311
290
 
312
- Inspect costs:
291
+ Disable compaction:
313
292
 
314
293
  ```ruby
315
- turn.cost.total
316
- conversation.cost.total
317
- agent.cost.total
294
+ TurnKit.compaction = false
318
295
  ```
319
296
 
320
- Use RubyLLM registry prices by default.
321
-
322
- Override model rates:
297
+ Configure compaction:
323
298
 
324
299
  ```ruby
325
- TurnKit.cost_rates = {
326
- "my-model" => {
327
- input: 0.25,
328
- output: 1.00,
329
- cached_input: 0.05,
330
- cache_creation: 0.25
331
- }
300
+ TurnKit.compaction = {
301
+ model: "gpt-4.1-mini",
302
+ threshold: 0.75,
303
+ context_limit: 128_000
332
304
  }
333
305
  ```
334
306
 
335
- Override cost calculation:
307
+ Compact manually:
336
308
 
337
309
  ```ruby
338
- TurnKit.cost_calculator = ->(usage, model) do
339
- {
340
- input: usage.input_tokens * 0.25 / 1_000_000.0,
341
- output: usage.output_tokens * 1.00 / 1_000_000.0
342
- }
343
- end
310
+ conversation.compact!(focus: "billing migration")
344
311
  ```
345
312
 
346
- Limit turn cost:
313
+ Run the local smoke test:
347
314
 
348
- ```ruby
349
- agent = TurnKit::Agent.new(
350
- name: "analyst",
351
- cost_limit: 0.25
352
- )
353
- ```
354
-
355
- ### Prompt caching
356
-
357
- Enable prompt caching:
358
-
359
- ```ruby
360
- TurnKit.prompt_cache = :auto
361
- ```
362
-
363
- Disable prompt caching:
364
-
365
- ```ruby
366
- TurnKit.prompt_cache = :off
367
- ```
368
-
369
- Split custom prompts:
370
-
371
- ```ruby
372
- agent = TurnKit::Agent.new(
373
- name: "cached",
374
- system_prompt: [
375
- "Stable instructions and tool guidance.",
376
- TurnKit::SystemPrompt::CACHE_BOUNDARY,
377
- "Dynamic subject and live context."
378
- ].join("\n")
379
- )
380
- ```
381
-
382
- ### Custom clients
383
-
384
- Create a client:
385
-
386
- ```ruby
387
- class MyClient < TurnKit::Client
388
- def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, metadata: nil)
389
- TurnKit::Result.new(
390
- text: "provider response",
391
- model: model,
392
- usage: TurnKit::Usage.new(
393
- input_tokens: 100,
394
- output_tokens: 20,
395
- cached_tokens: 80,
396
- cache_write_tokens: 100
397
- )
398
- )
399
- end
400
- end
401
- ```
402
-
403
- Use the client:
404
-
405
- ```ruby
406
- TurnKit.client = MyClient.new
407
- ```
408
-
409
- Split cache sections:
410
-
411
- ```ruby
412
- stable, dynamic = TurnKit::SystemPrompt.split_cache_boundary(instructions)
315
+ ```sh
316
+ ruby script/manual_compaction.rb
413
317
  ```
414
318
 
415
319
  ### Rails
@@ -420,47 +324,18 @@ Install Rails persistence:
420
324
  bin/rails generate turnkit:install
421
325
  ```
422
326
 
423
- The installer creates:
424
-
425
- - `config/initializers/turnkit.rb`
426
- - `app/models/turnkit/conversation.rb`
427
- - `app/models/turnkit/turn.rb`
428
- - `app/models/turnkit/message.rb`
429
- - `app/models/turnkit/tool_execution.rb`
430
- - a migration for TurnKit persistence
431
-
432
- 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]`.
433
-
434
327
  Run migrations:
435
328
 
436
329
  ```sh
437
330
  bin/rails db:migrate
438
331
  ```
439
332
 
440
- Configure Rails:
441
-
442
- ```ruby
443
- TurnKit.store = TurnKit::ActiveRecordStore.new
444
- ```
445
-
446
- Suggested Rails file layout for your application AI code:
333
+ Use this layout:
447
334
 
448
335
  ```text
449
- app/models/assistant/
450
- tools/
451
- web_search.rb
452
- read_web_page.rb
453
- skills/
454
- prompts/
455
- ```
456
-
457
- If you prefer to keep AI infrastructure out of `app/models`, add an autoloaded directory such as:
458
-
459
- ```text
460
- app/ai/
461
- tools/
462
- skills/
463
- prompts/
336
+ app/ai/agents/
337
+ app/ai/tools/
338
+ app/ai/skills/
464
339
  ```
465
340
 
466
341
  Reconcile stale turns:
@@ -469,112 +344,63 @@ Reconcile stale turns:
469
344
  TurnKit.reconcile_stale!
470
345
  ```
471
346
 
472
- #### Debugging Rails persistence
473
-
474
- Inspect the latest persisted turn in a Rails console:
347
+ ## Options
475
348
 
476
- ```ruby
477
- turn = Turnkit::Turn.order(created_at: :desc).first
478
- turn.status
479
- turn.error
480
- turn.output_text
481
- ```
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. |
482
361
 
483
- Check whether the model actually called tools:
362
+ Set options globally:
484
363
 
485
364
  ```ruby
486
- Turnkit::ToolExecution
487
- .where(turn_uid: turn.uid)
488
- .order(:created_at)
489
- .map { |execution|
490
- {
491
- name: execution.tool_name,
492
- status: execution.status,
493
- arguments: execution.arguments,
494
- result_keys: execution.result&.keys,
495
- error: execution.error
496
- }
497
- }
365
+ TurnKit.default_model = "gpt-4.1-mini"
366
+ TurnKit.max_iterations = 25
367
+ TurnKit.timeout = 300
498
368
  ```
499
369
 
500
- #### Live smoke test
501
-
502
- Use a model whose provider key is configured, then run a real tool-using turn:
370
+ Set options per agent:
503
371
 
504
372
  ```ruby
505
- TurnKit.default_model = "gpt-4.1-mini"
506
-
507
373
  agent = TurnKit::Agent.new(
508
- name: "researcher",
509
- instructions: "Use web_search, then read_web_page, before answering.",
510
- tools: [
511
- Assistant::Tools::WebSearch,
512
- Assistant::Tools::ReadWebPage
513
- ]
514
- )
515
-
516
- turn = agent.conversation.ask(
517
- "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
518
378
  )
519
-
520
- puts turn.output_text
521
-
522
- pp Turnkit::ToolExecution
523
- .where(turn_uid: turn.id)
524
- .order(:created_at)
525
- .pluck(:tool_name, :status, :error)
526
379
  ```
527
380
 
528
- ## Options
529
-
530
- Configure defaults:
381
+ Enable thinking:
531
382
 
532
383
  ```ruby
533
- TurnKit.default_model = "claude-sonnet-4-5"
534
- TurnKit.max_iterations = 25
535
- TurnKit.timeout = 300
536
- TurnKit.max_depth = 3
537
- TurnKit.max_tool_executions = 100
538
- TurnKit.cost_limit = nil
539
- TurnKit.cost_rates = {}
540
- TurnKit.cost_calculator = nil
541
- TurnKit.prompt_cache = :auto
384
+ agent = TurnKit::Agent.new(
385
+ name: "reasoner",
386
+ model: "claude-sonnet-4-5",
387
+ thinking: { budget: 4_000 }
388
+ )
542
389
  ```
543
390
 
544
- Override an agent:
391
+ ## Upgrading
392
+
393
+ Add `output_data` for structured output persistence.
545
394
 
546
395
  ```ruby
547
- agent = TurnKit::Agent.new(
548
- name: "analyst",
549
- model: "gpt-4.1-mini",
550
- max_iterations: 10,
551
- timeout: 60,
552
- cost_limit: 0.25,
553
- thinking: { effort: :low }
554
- )
396
+ add_column :turnkit_turns, :output_data, :json
555
397
  ```
556
398
 
557
- | Option | Description |
558
- | --- | --- |
559
- | `default_model` | Set the default RubyLLM model. |
560
- | `client` | Set the model client. |
561
- | `store` | Set the conversation store. |
562
- | `max_iterations` | Limit model calls per turn. |
563
- | `timeout` | Limit seconds per root turn. |
564
- | `max_tool_executions` | Limit tool calls per root turn. |
565
- | `cost_limit` | Limit cost per root turn. |
566
- | `thinking` | Configure provider reasoning or extended thinking per agent. |
567
- | `cost_rates` | Override prices by model. |
568
- | `cost_calculator` | Override cost calculation. |
569
- | `prompt_cache` | Use provider prompt caching. |
399
+ Skip this step for new installs.
570
400
 
571
401
  ## Contributing
572
402
 
573
- Report bugs and open pull requests on GitHub:
574
-
575
- ```text
576
- https://github.com/samuelcouch/turnkit
577
- ```
403
+ Fork the project.
578
404
 
579
405
  Run tests:
580
406
 
@@ -582,6 +408,14 @@ Run tests:
582
408
  bundle exec rake test
583
409
  ```
584
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
+
585
419
  ## License
586
420
 
587
- See the MIT License.
421
+ Use this gem under the MIT License.