turnkit 0.2.6 → 0.2.8
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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +250 -369
- data/UPGRADE.md +346 -0
- data/lib/turnkit/adapters/ruby_llm.rb +69 -5
- data/lib/turnkit/agent.rb +67 -2
- data/lib/turnkit/client.rb +5 -1
- data/lib/turnkit/conversation.rb +6 -5
- data/lib/turnkit/error.rb +2 -0
- data/lib/turnkit/event.rb +25 -0
- data/lib/turnkit/fleet.rb +105 -0
- data/lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb +1 -0
- data/lib/turnkit/generators/turnkit/install/templates/initializer.rb +6 -0
- data/lib/turnkit/generators/turnkit/install_generator.rb +6 -0
- data/lib/turnkit/model_request.rb +35 -0
- data/lib/turnkit/record.rb +2 -1
- data/lib/turnkit/result.rb +3 -2
- data/lib/turnkit/run.rb +74 -0
- data/lib/turnkit/stores/active_record_store.rb +14 -4
- data/lib/turnkit/sub_agent_tool.rb +13 -4
- data/lib/turnkit/system_prompt.rb +32 -2
- data/lib/turnkit/tool.rb +152 -8
- data/lib/turnkit/tool_call.rb +3 -1
- data/lib/turnkit/tool_runner.rb +21 -6
- data/lib/turnkit/turn.rb +92 -15
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +30 -0
- metadata +10 -6
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.ruby-lang.org)
|
|
5
5
|
[](LICENSE.md)
|
|
6
6
|
|
|
7
|
-
Build durable Ruby
|
|
7
|
+
Build durable Ruby and Rails agents with tools, skills, sub-agents, and persistence.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -20,23 +20,16 @@ Run:
|
|
|
20
20
|
bundle install
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
Upgrading from an earlier TurnKit version? See the [Upgrade Guide](UPGRADE.md).
|
|
24
|
+
|
|
23
25
|
## Quick Start
|
|
24
26
|
|
|
25
|
-
Set
|
|
27
|
+
Set an API key:
|
|
26
28
|
|
|
27
29
|
```sh
|
|
28
30
|
export ANTHROPIC_API_KEY=...
|
|
29
31
|
```
|
|
30
32
|
|
|
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
33
|
Create an agent:
|
|
41
34
|
|
|
42
35
|
```ruby
|
|
@@ -55,73 +48,49 @@ turn = agent.conversation.ask("Explain Ruby blocks in one sentence.")
|
|
|
55
48
|
puts turn.output_text
|
|
56
49
|
```
|
|
57
50
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
### Models
|
|
61
|
-
|
|
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:
|
|
51
|
+
Or run a non-interactive application task:
|
|
75
52
|
|
|
76
53
|
```ruby
|
|
77
|
-
|
|
54
|
+
run = agent.run("Explain Ruby blocks in one sentence.")
|
|
55
|
+
puts run.output
|
|
78
56
|
```
|
|
79
57
|
|
|
80
|
-
|
|
58
|
+
## Usage
|
|
81
59
|
|
|
82
|
-
|
|
83
|
-
export GEMINI_API_KEY=...
|
|
84
|
-
```
|
|
60
|
+
### Models
|
|
85
61
|
|
|
86
|
-
Set a
|
|
62
|
+
Set a model:
|
|
87
63
|
|
|
88
64
|
```ruby
|
|
89
|
-
TurnKit.
|
|
65
|
+
TurnKit.model = "gpt-4.1-mini"
|
|
90
66
|
```
|
|
91
67
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
Enable provider reasoning or extended thinking per agent:
|
|
68
|
+
Or configure TurnKit in one place:
|
|
95
69
|
|
|
96
70
|
```ruby
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
71
|
+
TurnKit.configure do |config|
|
|
72
|
+
config.model = "gpt-4.1-mini"
|
|
73
|
+
config.max_spend = 0.25
|
|
74
|
+
config.max_iterations = 12
|
|
75
|
+
end
|
|
102
76
|
```
|
|
103
77
|
|
|
104
|
-
|
|
78
|
+
Set the matching key:
|
|
105
79
|
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
name: "reasoner",
|
|
109
|
-
model: "gemini-2.5-flash",
|
|
110
|
-
thinking: { effort: :high }
|
|
111
|
-
)
|
|
80
|
+
```sh
|
|
81
|
+
export OPENAI_API_KEY=...
|
|
112
82
|
```
|
|
113
83
|
|
|
114
|
-
|
|
84
|
+
Use these common providers:
|
|
115
85
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
86
|
+
| Provider | Key | Model |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-5` |
|
|
89
|
+
| OpenAI | `OPENAI_API_KEY` | `gpt-4.1-mini` |
|
|
90
|
+
| Gemini | `GEMINI_API_KEY` | `gemini-2.5-flash` |
|
|
91
|
+
| OpenRouter | `OPENROUTER_API_KEY` | `openrouter/...` |
|
|
123
92
|
|
|
124
|
-
|
|
93
|
+
Expect `TurnKit::ModelAccessError` for obvious key mistakes.
|
|
125
94
|
|
|
126
95
|
### Conversations
|
|
127
96
|
|
|
@@ -132,12 +101,13 @@ agent = TurnKit::Agent.new(
|
|
|
132
101
|
name: "writer",
|
|
133
102
|
instructions: "Write clear release notes."
|
|
134
103
|
)
|
|
104
|
+
|
|
105
|
+
conversation = agent.conversation(subject: "v1 launch")
|
|
135
106
|
```
|
|
136
107
|
|
|
137
108
|
Add context:
|
|
138
109
|
|
|
139
110
|
```ruby
|
|
140
|
-
conversation = agent.conversation(subject: "v1 launch")
|
|
141
111
|
conversation.say("Mention faster tool execution.")
|
|
142
112
|
```
|
|
143
113
|
|
|
@@ -148,91 +118,146 @@ turn = conversation.run!
|
|
|
148
118
|
puts turn.output_text
|
|
149
119
|
```
|
|
150
120
|
|
|
151
|
-
###
|
|
121
|
+
### Application Tasks
|
|
152
122
|
|
|
153
|
-
|
|
123
|
+
Use `Agent#run` when your application is executing a task instead of chatting
|
|
124
|
+
with a user:
|
|
154
125
|
|
|
155
126
|
```ruby
|
|
156
|
-
|
|
157
|
-
|
|
127
|
+
agent = TurnKit::Agent.new(
|
|
128
|
+
name: "lead_classifier",
|
|
129
|
+
instructions: "Classify leads and return routing data.",
|
|
130
|
+
output_schema: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
priority: { type: "string" },
|
|
134
|
+
reason: { type: "string" }
|
|
135
|
+
},
|
|
136
|
+
required: ["priority", "reason"]
|
|
137
|
+
},
|
|
138
|
+
prompt_mode: :task
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
run = agent.run(
|
|
142
|
+
"Classify this lead.",
|
|
143
|
+
input: { company: "Acme", employees: 1_200 }
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
puts run.output_data
|
|
158
147
|
```
|
|
159
148
|
|
|
160
|
-
|
|
149
|
+
`Agent#run` is a small wrapper over TurnKit's existing conversation and turn
|
|
150
|
+
engine. Existing `conversation.ask` usage is still supported.
|
|
161
151
|
|
|
162
|
-
|
|
152
|
+
Prepare a pending run without calling the model:
|
|
163
153
|
|
|
164
154
|
```ruby
|
|
165
|
-
|
|
155
|
+
run = agent.run(task: "Classify later.", async: true)
|
|
156
|
+
request = run.preview
|
|
157
|
+
run.run!
|
|
166
158
|
```
|
|
167
159
|
|
|
168
|
-
|
|
160
|
+
### Fleets
|
|
161
|
+
|
|
162
|
+
Use a fleet when you want to package a reusable autonomous workflow: one
|
|
163
|
+
task-mode orchestrator, workflow skills, tools, defaults, and guardrails. A
|
|
164
|
+
fleet is not a requirement for multi-agent work; it is the reusable runtime for
|
|
165
|
+
getting from input to output.
|
|
169
166
|
|
|
170
167
|
```ruby
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
168
|
+
source_grounded_brief = TurnKit::Skill.from_file("app/ai/skills/source_grounded_brief.md")
|
|
169
|
+
|
|
170
|
+
fleet = TurnKit.fleet(
|
|
171
|
+
"brief_writer",
|
|
172
|
+
instructions: "Create source-grounded briefs and verify claims before final output.",
|
|
173
|
+
skills: [source_grounded_brief],
|
|
174
|
+
tools: [WebSearch.new, ReadWebPage.new, SaveBrief],
|
|
175
|
+
max_spend: 0.25,
|
|
176
|
+
max_iterations: 12,
|
|
177
|
+
max_tool_executions: 25,
|
|
178
|
+
compaction: {
|
|
179
|
+
context_limit: 64_000,
|
|
180
|
+
threshold: 0.75
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
run = fleet.run(
|
|
185
|
+
"Create a source-grounded brief.",
|
|
186
|
+
input: { topic: "Rails 8 Solid Queue" }
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
puts run.output
|
|
190
|
+
puts run.tool_calls.map(&:tool_name)
|
|
191
|
+
puts run.cost.total
|
|
174
192
|
```
|
|
175
193
|
|
|
176
|
-
|
|
194
|
+
This keeps the work in a single conversation and uses TurnKit's normal
|
|
195
|
+
model-tool loop:
|
|
177
196
|
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
model: "gpt-4.1-mini",
|
|
181
|
-
threshold: 0.75,
|
|
182
|
-
context_limit: 128_000
|
|
183
|
-
}
|
|
197
|
+
```text
|
|
198
|
+
model → tool → result → model → tool → result → final
|
|
184
199
|
```
|
|
185
200
|
|
|
186
|
-
|
|
201
|
+
`auto_run` is an alias for `run` when you want the name to emphasize autonomous
|
|
202
|
+
execution:
|
|
187
203
|
|
|
188
204
|
```ruby
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
205
|
+
run = fleet.auto_run(
|
|
206
|
+
"Create compliant outreach for this account.",
|
|
207
|
+
input: lead.attributes,
|
|
208
|
+
max_spend: 0.25,
|
|
209
|
+
max_iterations: 8,
|
|
210
|
+
max_tool_executions: 20,
|
|
192
211
|
compaction: {
|
|
193
|
-
|
|
194
|
-
threshold: 0.75
|
|
195
|
-
context_limit: 128_000
|
|
212
|
+
context_limit: 64_000,
|
|
213
|
+
threshold: 0.75
|
|
196
214
|
}
|
|
197
215
|
)
|
|
198
216
|
```
|
|
199
217
|
|
|
200
|
-
|
|
218
|
+
Reach for separate agents and `sub_agents` only when the isolation is worth the
|
|
219
|
+
extra model calls, such as different models, different tool permissions,
|
|
220
|
+
parallel specialist review, or separate durable child conversations.
|
|
201
221
|
|
|
202
|
-
|
|
222
|
+
Use `terminal!` for save or action tools that complete the run:
|
|
203
223
|
|
|
204
224
|
```ruby
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
225
|
+
class SaveBrief < TurnKit::Tool
|
|
226
|
+
description "Save the final brief."
|
|
227
|
+
parameter :title, :string, required: true
|
|
228
|
+
parameter :body, :string, required: true
|
|
208
229
|
|
|
209
|
-
|
|
230
|
+
terminal! { |result| "Saved #{result.fetch("id")}." }
|
|
210
231
|
|
|
211
|
-
|
|
212
|
-
|
|
232
|
+
def call(title:, body:, context:)
|
|
233
|
+
Brief.create!(title: title, body: body).then { |brief| { id: brief.id } }
|
|
234
|
+
end
|
|
235
|
+
end
|
|
213
236
|
```
|
|
214
237
|
|
|
215
|
-
|
|
238
|
+
### Prompt Preview
|
|
239
|
+
|
|
240
|
+
Preview a pending turn:
|
|
216
241
|
|
|
217
242
|
```ruby
|
|
218
|
-
conversation.
|
|
219
|
-
|
|
243
|
+
turn = conversation.ask("Draft the launch email.", async: true)
|
|
244
|
+
request = turn.preview
|
|
220
245
|
```
|
|
221
246
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
The model-visible projection uses a synthetic summary exchange followed by recent messages:
|
|
247
|
+
Inspect the request:
|
|
225
248
|
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
249
|
+
```ruby
|
|
250
|
+
request.model
|
|
251
|
+
request.messages
|
|
252
|
+
request.tool_names
|
|
253
|
+
request.instructions
|
|
254
|
+
request.report
|
|
230
255
|
```
|
|
231
256
|
|
|
232
|
-
|
|
257
|
+
Run the reviewed turn:
|
|
233
258
|
|
|
234
|
-
```
|
|
235
|
-
|
|
259
|
+
```ruby
|
|
260
|
+
turn.run!
|
|
236
261
|
```
|
|
237
262
|
|
|
238
263
|
### Tools
|
|
@@ -243,11 +268,15 @@ Create a tool:
|
|
|
243
268
|
class SaveReport < TurnKit::Tool
|
|
244
269
|
description "Save a report."
|
|
245
270
|
usage_hint "Use when the user asks to persist a report."
|
|
271
|
+
|
|
246
272
|
parameter :title, :string, required: true
|
|
247
273
|
parameter :body, :string, required: true
|
|
248
274
|
|
|
249
275
|
def self.ends_turn? = true
|
|
250
|
-
|
|
276
|
+
|
|
277
|
+
def self.completion_message(result)
|
|
278
|
+
"Saved #{result.fetch("report_id")}."
|
|
279
|
+
end
|
|
251
280
|
|
|
252
281
|
def call(title:, body:, context:)
|
|
253
282
|
{ report_id: "rep_1", title: title, body: body }
|
|
@@ -255,7 +284,7 @@ class SaveReport < TurnKit::Tool
|
|
|
255
284
|
end
|
|
256
285
|
```
|
|
257
286
|
|
|
258
|
-
|
|
287
|
+
Register the tool:
|
|
259
288
|
|
|
260
289
|
```ruby
|
|
261
290
|
agent = TurnKit::Agent.new(
|
|
@@ -265,82 +294,87 @@ agent = TurnKit::Agent.new(
|
|
|
265
294
|
)
|
|
266
295
|
```
|
|
267
296
|
|
|
268
|
-
|
|
297
|
+
Run the tool loop:
|
|
269
298
|
|
|
270
299
|
```ruby
|
|
271
300
|
turn = agent.conversation.ask("Save a short status report.")
|
|
272
301
|
puts turn.output_text
|
|
273
302
|
```
|
|
274
303
|
|
|
275
|
-
|
|
304
|
+
Rely on TurnKit to validate tools and model-provided arguments.
|
|
276
305
|
|
|
277
|
-
|
|
306
|
+
### Structured Output
|
|
307
|
+
|
|
308
|
+
Define a schema:
|
|
278
309
|
|
|
279
310
|
```ruby
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
311
|
+
schema = {
|
|
312
|
+
type: "object",
|
|
313
|
+
properties: {
|
|
314
|
+
title: { type: "string" },
|
|
315
|
+
bullets: {
|
|
316
|
+
type: "array",
|
|
317
|
+
items: { type: "string" }
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
required: ["title", "bullets"]
|
|
321
|
+
}
|
|
322
|
+
```
|
|
285
323
|
|
|
286
|
-
|
|
287
|
-
parameter :search_queries, :array, required: false
|
|
324
|
+
Use structured output:
|
|
288
325
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
end
|
|
326
|
+
```ruby
|
|
327
|
+
agent = TurnKit::Agent.new(
|
|
328
|
+
name: "writer",
|
|
329
|
+
output_schema: schema
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
turn = agent.conversation.ask("Summarize the launch plan.")
|
|
333
|
+
puts turn.output_data
|
|
298
334
|
```
|
|
299
335
|
|
|
300
|
-
|
|
336
|
+
Override the schema per turn:
|
|
301
337
|
|
|
302
338
|
```ruby
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
339
|
+
conversation.ask(
|
|
340
|
+
"Return one decision.",
|
|
341
|
+
output_schema: {
|
|
342
|
+
type: "object",
|
|
343
|
+
properties: {
|
|
344
|
+
decision: { type: "string" }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
309
347
|
)
|
|
310
348
|
```
|
|
311
349
|
|
|
312
|
-
|
|
350
|
+
### Events
|
|
313
351
|
|
|
314
|
-
|
|
352
|
+
Subscribe globally:
|
|
315
353
|
|
|
316
354
|
```ruby
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
context.execution # The TurnKit::ToolExecution for this tool call
|
|
320
|
-
|
|
321
|
-
{ query: query }
|
|
355
|
+
TurnKit.on_event = ->(event) do
|
|
356
|
+
Rails.logger.info("turnkit.#{event.type}")
|
|
322
357
|
end
|
|
323
358
|
```
|
|
324
359
|
|
|
325
|
-
|
|
360
|
+
Subscribe per agent:
|
|
326
361
|
|
|
327
362
|
```ruby
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
363
|
+
agent = TurnKit::Agent.new(
|
|
364
|
+
name: "helper",
|
|
365
|
+
on_event: ->(event) { puts event.type }
|
|
366
|
+
)
|
|
331
367
|
```
|
|
332
368
|
|
|
333
|
-
|
|
369
|
+
Subscribe per turn:
|
|
334
370
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
| `Array` | Wrapped as `{ "items" => [...] }`. |
|
|
341
|
-
| Scalar | Wrapped as `{ "result" => value.to_s }`. |
|
|
371
|
+
```ruby
|
|
372
|
+
turn.run! do |event|
|
|
373
|
+
puts event.type
|
|
374
|
+
end
|
|
375
|
+
```
|
|
342
376
|
|
|
343
|
-
|
|
377
|
+
Use events for turns, model calls, messages, and tool calls.
|
|
344
378
|
|
|
345
379
|
### Skills
|
|
346
380
|
|
|
@@ -370,7 +404,7 @@ writer = TurnKit::Agent.new(
|
|
|
370
404
|
)
|
|
371
405
|
```
|
|
372
406
|
|
|
373
|
-
|
|
407
|
+
Register the sub-agent:
|
|
374
408
|
|
|
375
409
|
```ruby
|
|
376
410
|
editor = TurnKit::Agent.new(
|
|
@@ -386,117 +420,36 @@ turn = editor.conversation.ask("Ask the writer for three headlines.")
|
|
|
386
420
|
puts turn.output_text
|
|
387
421
|
```
|
|
388
422
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
Inspect token usage:
|
|
423
|
+
Use sub-agents for isolated child conversations.
|
|
392
424
|
|
|
393
|
-
|
|
394
|
-
turn.usage.total_tokens
|
|
395
|
-
conversation.usage.total_tokens
|
|
396
|
-
agent.usage.total_tokens
|
|
397
|
-
```
|
|
425
|
+
### Context Compaction
|
|
398
426
|
|
|
399
|
-
|
|
427
|
+
Disable compaction:
|
|
400
428
|
|
|
401
429
|
```ruby
|
|
402
|
-
|
|
403
|
-
conversation.cost.total
|
|
404
|
-
agent.cost.total
|
|
430
|
+
TurnKit.compaction = false
|
|
405
431
|
```
|
|
406
432
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
Override model rates:
|
|
433
|
+
Configure compaction:
|
|
410
434
|
|
|
411
435
|
```ruby
|
|
412
|
-
TurnKit.
|
|
413
|
-
"
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
cached_input: 0.05,
|
|
417
|
-
cache_creation: 0.25
|
|
418
|
-
}
|
|
436
|
+
TurnKit.compaction = {
|
|
437
|
+
model: "gpt-4.1-mini",
|
|
438
|
+
threshold: 0.75,
|
|
439
|
+
context_limit: 128_000
|
|
419
440
|
}
|
|
420
441
|
```
|
|
421
442
|
|
|
422
|
-
|
|
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:
|
|
434
|
-
|
|
435
|
-
```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:
|
|
443
|
+
Compact manually:
|
|
491
444
|
|
|
492
445
|
```ruby
|
|
493
|
-
|
|
446
|
+
conversation.compact!(focus: "billing migration")
|
|
494
447
|
```
|
|
495
448
|
|
|
496
|
-
|
|
449
|
+
Run the local smoke test:
|
|
497
450
|
|
|
498
|
-
```
|
|
499
|
-
|
|
451
|
+
```sh
|
|
452
|
+
ruby script/manual_compaction.rb
|
|
500
453
|
```
|
|
501
454
|
|
|
502
455
|
### Rails
|
|
@@ -507,47 +460,18 @@ Install Rails persistence:
|
|
|
507
460
|
bin/rails generate turnkit:install
|
|
508
461
|
```
|
|
509
462
|
|
|
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
463
|
Run migrations:
|
|
522
464
|
|
|
523
465
|
```sh
|
|
524
466
|
bin/rails db:migrate
|
|
525
467
|
```
|
|
526
468
|
|
|
527
|
-
|
|
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:
|
|
469
|
+
Use this layout:
|
|
545
470
|
|
|
546
471
|
```text
|
|
547
|
-
app/ai/
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
prompts/
|
|
472
|
+
app/ai/agents/
|
|
473
|
+
app/ai/tools/
|
|
474
|
+
app/ai/skills/
|
|
551
475
|
```
|
|
552
476
|
|
|
553
477
|
Reconcile stale turns:
|
|
@@ -556,114 +480,63 @@ Reconcile stale turns:
|
|
|
556
480
|
TurnKit.reconcile_stale!
|
|
557
481
|
```
|
|
558
482
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
Inspect the latest persisted turn in a Rails console:
|
|
483
|
+
## Options
|
|
562
484
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
485
|
+
| Option | Description |
|
|
486
|
+
| --- | --- |
|
|
487
|
+
| `TurnKit.default_model` | Set the default model. |
|
|
488
|
+
| `TurnKit.client` | Set the model client. |
|
|
489
|
+
| `TurnKit.store` | Set the persistence store. |
|
|
490
|
+
| `TurnKit.max_iterations` | Limit model loop iterations. |
|
|
491
|
+
| `TurnKit.max_depth` | Limit sub-agent depth. |
|
|
492
|
+
| `TurnKit.max_tool_executions` | Limit tool calls per turn. |
|
|
493
|
+
| `TurnKit.timeout` | Limit turn runtime. |
|
|
494
|
+
| `TurnKit.cost_limit` | Limit estimated turn cost. |
|
|
495
|
+
| `TurnKit.compaction` | Configure context compaction. |
|
|
496
|
+
| `TurnKit.on_event` | Subscribe to lifecycle events. |
|
|
569
497
|
|
|
570
|
-
|
|
498
|
+
Set options globally:
|
|
571
499
|
|
|
572
500
|
```ruby
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
}
|
|
501
|
+
TurnKit.default_model = "gpt-4.1-mini"
|
|
502
|
+
TurnKit.max_iterations = 25
|
|
503
|
+
TurnKit.timeout = 300
|
|
585
504
|
```
|
|
586
505
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
Use a model whose provider key is configured, then run a real tool-using turn:
|
|
506
|
+
Set options per agent:
|
|
590
507
|
|
|
591
508
|
```ruby
|
|
592
|
-
TurnKit.default_model = "gpt-4.1-mini"
|
|
593
|
-
|
|
594
509
|
agent = TurnKit::Agent.new(
|
|
595
|
-
name: "
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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."
|
|
510
|
+
name: "engineer",
|
|
511
|
+
model: "gpt-4.1-mini",
|
|
512
|
+
max_iterations: 10,
|
|
513
|
+
max_depth: 2
|
|
605
514
|
)
|
|
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
515
|
```
|
|
614
516
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
Configure defaults:
|
|
517
|
+
Enable thinking:
|
|
618
518
|
|
|
619
519
|
```ruby
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
TurnKit.cost_limit = nil
|
|
626
|
-
TurnKit.cost_rates = {}
|
|
627
|
-
TurnKit.cost_calculator = nil
|
|
628
|
-
TurnKit.prompt_cache = :auto
|
|
629
|
-
TurnKit.compaction = true
|
|
520
|
+
agent = TurnKit::Agent.new(
|
|
521
|
+
name: "reasoner",
|
|
522
|
+
model: "claude-sonnet-4-5",
|
|
523
|
+
thinking: { budget: 4_000 }
|
|
524
|
+
)
|
|
630
525
|
```
|
|
631
526
|
|
|
632
|
-
|
|
527
|
+
## Upgrading
|
|
528
|
+
|
|
529
|
+
Add `output_data` for structured output persistence.
|
|
633
530
|
|
|
634
531
|
```ruby
|
|
635
|
-
|
|
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
|
-
)
|
|
532
|
+
add_column :turnkit_turns, :output_data, :json
|
|
643
533
|
```
|
|
644
534
|
|
|
645
|
-
|
|
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. |
|
|
535
|
+
Skip this step for new installs.
|
|
659
536
|
|
|
660
537
|
## Contributing
|
|
661
538
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
```text
|
|
665
|
-
https://github.com/samuelcouch/turnkit
|
|
666
|
-
```
|
|
539
|
+
Fork the project.
|
|
667
540
|
|
|
668
541
|
Run tests:
|
|
669
542
|
|
|
@@ -671,6 +544,14 @@ Run tests:
|
|
|
671
544
|
bundle exec rake test
|
|
672
545
|
```
|
|
673
546
|
|
|
547
|
+
Run syntax checks:
|
|
548
|
+
|
|
549
|
+
```sh
|
|
550
|
+
find lib test examples -type f -name '*.rb' -print0 | xargs -0 ruby -c
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
Open a pull request.
|
|
554
|
+
|
|
674
555
|
## License
|
|
675
556
|
|
|
676
|
-
|
|
557
|
+
Use this gem under the MIT License.
|