turnkit 0.2.2 → 0.2.3

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: be681d2deacaf1e3be9de2eb84eef412a686baf90a8b0c0a41280cf6a76ecc55
4
- data.tar.gz: f0e6d232f50a67ce4a2cd5c46360549b7755a7b6ab100968bd9a2bf16f3cab0a
3
+ metadata.gz: 2c02ad5eef683595c702a33806438f414ed2da9e18c607a8b314bba4ae442404
4
+ data.tar.gz: 4da3877b7c20aecae1dd77e6df4497bb64a3909d28419fb1413feb37fa5fa298
5
5
  SHA512:
6
- metadata.gz: dc9fbeca56bbdc7e737a56dcbb0caa87eb17186c035f052285532749e1e27546884d020c216c72f008950ad38053fc67dbd71e5cfd8d572f169029d4a78ba116
7
- data.tar.gz: ae8b955e099d1d81026ff34b3bae9e4a5009122e1e8cccaa64aed0675f888c0cb12fcae503781daa6ad710e8f9f56aec2387c1ce856cf66d6850430141b28dfe
6
+ metadata.gz: b5de4c365826d8a4154d2ee013fe0f7289796b91b63eb34ad81693993eb55b8f8d0282f8415e7798f9eb698d2f6f4aa52b79949e1c89c0c64effe506cf26ef0b
7
+ data.tar.gz: b168324cf4f97485ce7854006565441fd0fe67e1f84835805d98d67f27a2a793fe2ce8bd27a6939c6ccbf3cc92023bc93c8aff5e8049fb0b2991a50548d211d6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3 - 2026-06-06
4
+
5
+ - Add Anthropic prompt cache support for stable system prompt sections.
6
+ - Track cache write tokens and aggregate model costs on turns.
7
+ - Refresh README usage examples for prompt caching and usage tracking.
8
+
3
9
  ## 0.2.0 - 2026-06-04
4
10
 
5
11
  - Add configurable system prompt sections and custom system prompt builders.
data/README.md CHANGED
@@ -26,33 +26,9 @@ Set a provider key:
26
26
 
27
27
  ```sh
28
28
  export ANTHROPIC_API_KEY=...
29
- # or OPENAI_API_KEY=..., GEMINI_API_KEY=..., OPENROUTER_API_KEY=...
30
29
  ```
31
30
 
32
- TurnKit uses RubyLLM by default. Choose the provider by choosing a RubyLLM model name:
33
-
34
- ```ruby
35
- TurnKit.default_model = "claude-sonnet-4-5" # Anthropic
36
- # TurnKit.default_model = "gpt-4.1-mini" # OpenAI
37
- # TurnKit.default_model = "gemini-2.5-flash" # Gemini
38
- ```
39
-
40
- You can also override the model per agent or per run.
41
-
42
- To use a different model SDK, provide a client object that responds to `chat`:
43
-
44
- ```ruby
45
- class MyClient < TurnKit::Client
46
- def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
47
- # Call your provider here.
48
- TurnKit::Result.new(text: "provider response", model: model)
49
- end
50
- end
51
-
52
- TurnKit.client = MyClient.new
53
- ```
54
-
55
- Ask an agent:
31
+ Create an agent:
56
32
 
57
33
  ```ruby
58
34
  require "turnkit"
@@ -68,6 +44,22 @@ puts turn.output_text
68
44
 
69
45
  ## Usage
70
46
 
47
+ Choose a model:
48
+
49
+ ```ruby
50
+ TurnKit.default_model = "claude-sonnet-4-5"
51
+ ```
52
+
53
+ Use OpenAI:
54
+
55
+ ```sh
56
+ export OPENAI_API_KEY=...
57
+ ```
58
+
59
+ ```ruby
60
+ TurnKit.default_model = "gpt-4.1-mini"
61
+ ```
62
+
71
63
  Create a conversation:
72
64
 
73
65
  ```ruby
@@ -101,7 +93,7 @@ class SaveReport < TurnKit::Tool
101
93
  end
102
94
  ```
103
95
 
104
- Use the tool:
96
+ Use a tool:
105
97
 
106
98
  ```ruby
107
99
  agent = TurnKit::Agent.new(
@@ -125,142 +117,99 @@ agent = TurnKit::Agent.new(
125
117
  )
126
118
  ```
127
119
 
128
- List available skills:
120
+ Delegate to sub-agents:
129
121
 
130
122
  ```ruby
131
- research = TurnKit::Skill.from_file(
132
- "skills/research.md",
133
- description: "Use for source-backed research tasks."
123
+ writer = TurnKit::Agent.new(
124
+ name: "writer",
125
+ description: "Draft concise copy."
134
126
  )
135
127
 
136
- agent = TurnKit::Agent.new(
137
- name: "researcher",
138
- instructions: "Prefer primary sources.",
139
- tools: [WebSearch, ReadWebPage],
140
- available_skills: [research]
128
+ editor = TurnKit::Agent.new(
129
+ name: "editor",
130
+ sub_agents: [writer]
141
131
  )
142
- ```
143
132
 
144
- Add subject context:
145
-
146
- ```ruby
147
- article = Article.find(1)
148
- conversation = agent.conversation(subject: article)
133
+ turn = editor.conversation.ask("Ask the writer for three headlines.")
134
+ puts turn.output_text
149
135
  ```
150
136
 
151
- Choose prompt sections:
137
+ Use prompt caching:
152
138
 
153
139
  ```ruby
154
- agent = TurnKit::Agent.new(
155
- name: "writer",
156
- instructions: "Write plainly.",
157
- prompt_sections: %i[agent instructions tools environment]
158
- )
140
+ TurnKit.prompt_cache = :auto
159
141
  ```
160
142
 
161
- Build a custom prompt:
143
+ Disable prompt caching:
162
144
 
163
145
  ```ruby
164
- agent = TurnKit::Agent.new(
165
- name: "custom",
166
- instructions: "Answer in JSON.",
167
- system_prompt: ->(prompt) {
168
- [
169
- prompt.agent_section,
170
- prompt.instructions_section,
171
- "Return only valid JSON."
172
- ].compact.join("\n\n")
173
- }
174
- )
146
+ TurnKit.prompt_cache = :off
175
147
  ```
176
148
 
177
- Use safe prompt data blocks for pipeline-specific prompts:
149
+ Split custom prompts:
178
150
 
179
151
  ```ruby
180
152
  agent = TurnKit::Agent.new(
181
- name: "researcher",
182
- system_prompt: ->(prompt) {
183
- [
184
- prompt.section(:agent),
185
- prompt.section(:behavior),
186
- prompt.untrusted_section(
187
- :retrieval_context,
188
- ExternalSearch.results_for("turnkit"),
189
- label: "Retrieved external evidence."
190
- ),
191
- prompt.section(:tools),
192
- prompt.section(:environment)
193
- ].compact.join("\n\n")
194
- }
153
+ name: "cached",
154
+ system_prompt: [
155
+ "Stable instructions and tool guidance.",
156
+ TurnKit::SystemPrompt::CACHE_BOUNDARY,
157
+ "Dynamic subject and live context."
158
+ ].join("\n")
195
159
  )
196
160
  ```
197
161
 
198
- Choose a prompt mode:
162
+ Inspect usage:
199
163
 
200
164
  ```ruby
201
- TurnKit::Agent.new(name: "main", prompt_mode: :full) # default sections
202
- TurnKit::Agent.new(name: "worker", prompt_mode: :minimal) # agent, instructions, behavior, tools, environment
203
- TurnKit::Agent.new(name: "raw", prompt_mode: :none) # tiny TurnKit identity prompt
165
+ record = TurnKit.store.load_turn(turn.id)
166
+ record.fetch("usage")
204
167
  ```
205
168
 
206
- TurnKit automatically uses the minimal prompt mode for delegated sub-agent turns unless the child agent sets its own `prompt_mode`.
207
-
208
- Inject live context on each turn:
169
+ Return usage from custom clients:
209
170
 
210
171
  ```ruby
211
- TurnKit.context_contributors << ->(context) {
212
- TurnKit::LiveContextContribution.new(
213
- name: "account",
214
- content: AccountSummary.for(context.conversation.metadata["account_id"]),
215
- trusted: false
216
- )
217
- }
172
+ class MyClient < TurnKit::Client
173
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
174
+ TurnKit::Result.new(
175
+ text: "provider response",
176
+ model: model,
177
+ usage: TurnKit::Usage.new(
178
+ input_tokens: 100,
179
+ output_tokens: 20,
180
+ cached_tokens: 80,
181
+ cache_write_tokens: 100
182
+ )
183
+ )
184
+ end
185
+ end
218
186
  ```
219
187
 
220
- Live context and subject context are rendered below `TurnKit::SystemPrompt::CACHE_BOUNDARY`, so provider adapters can reuse the stable prefix in the future.
221
-
222
- Add model-specific prompt guidance:
188
+ Split instructions inside custom clients:
223
189
 
224
190
  ```ruby
225
- TurnKit.model_prompt_contributors[/claude/] = ->(context) {
226
- TurnKit::PromptContribution.new(
227
- stable_prefix: "Provider guidance for #{context.model}.",
228
- section_overrides: {
229
- behavior: "Be concise, tool-aware, and explicit about uncertainty."
230
- }
231
- )
232
- }
191
+ stable, dynamic = TurnKit::SystemPrompt.split_cache_boundary(instructions)
233
192
  ```
234
193
 
235
- Inspect prompt shape without storing raw prompt text:
194
+ Send `stable` with provider cache controls.
236
195
 
237
- ```ruby
238
- prompt = TurnKit::SystemPrompt.new(agent: agent, turn: turn, conversation: conversation)
239
- prompt.report
240
- # => { "chars" => ..., "hash" => ..., "stable_chars" => ..., "dynamic_chars" => ... }
241
- ```
196
+ Send `dynamic` as normal prompt content.
242
197
 
243
- Delegate to sub-agents:
198
+ Use a custom client:
244
199
 
245
200
  ```ruby
246
- writer = TurnKit::Agent.new(
247
- name: "writer",
248
- description: "Draft concise copy."
249
- )
250
-
251
- editor = TurnKit::Agent.new(
252
- name: "editor",
253
- sub_agents: [writer]
254
- )
255
-
256
- turn = editor.conversation.ask("Ask the writer for three headlines.")
257
- puts turn.output_text
201
+ TurnKit.client = MyClient.new
258
202
  ```
259
203
 
260
204
  Install Rails persistence:
261
205
 
262
206
  ```sh
263
207
  bin/rails generate turnkit:install
208
+ ```
209
+
210
+ Run migrations:
211
+
212
+ ```sh
264
213
  bin/rails db:migrate
265
214
  ```
266
215
 
@@ -269,7 +218,6 @@ Configure Rails:
269
218
  ```ruby
270
219
  TurnKit.store = TurnKit::ActiveRecordStore.new
271
220
  TurnKit.default_model = "claude-sonnet-4-5"
272
- TurnKit.timeout = 300
273
221
  ```
274
222
 
275
223
  Reconcile stale turns:
@@ -289,9 +237,10 @@ TurnKit.timeout = 300
289
237
  TurnKit.max_depth = 3
290
238
  TurnKit.max_tool_executions = 100
291
239
  TurnKit.cost_limit = nil
240
+ TurnKit.prompt_cache = :auto
292
241
  ```
293
242
 
294
- Override defaults per agent:
243
+ Override an agent:
295
244
 
296
245
  ```ruby
297
246
  agent = TurnKit::Agent.new(
@@ -303,29 +252,18 @@ agent = TurnKit::Agent.new(
303
252
  )
304
253
  ```
305
254
 
306
- Override the model for a single conversation or turn:
307
-
308
- ```ruby
309
- conversation = agent.conversation(model: "claude-opus-4-1")
310
- turn = conversation.run!(model: "gpt-4.1-mini")
311
- ```
312
-
313
255
  | Option | Description |
314
256
  | --- | --- |
315
- | `default_model` | Set the default RubyLLM model. The model name determines the provider. |
316
- | `client` | Set the model client. Defaults to `TurnKit::Adapters::RubyLLM.new`. |
257
+ | `default_model` | Set the default RubyLLM model. |
258
+ | `client` | Set the model client. |
317
259
  | `store` | Set the conversation store. |
318
260
  | `max_iterations` | Limit model calls per turn. |
319
261
  | `timeout` | Limit seconds per root turn. |
320
262
  | `max_depth` | Limit sub-agent nesting. |
321
263
  | `max_tool_executions` | Limit tool calls per root turn. |
322
264
  | `cost_limit` | Limit cost per root turn. |
323
- | `prompt_sections` | Set default system prompt sections. |
324
- | `prompt_behavior` | Override the default behavior section text. |
325
- | `prompt_data_max_chars` | Limit data-block content rendered into prompts. |
326
- | `context_contributors` | Add live per-turn prompt context blocks. |
327
- | `system_prompt_contributors` | Add global prompt prefix/suffix/section overrides. |
328
- | `model_prompt_contributors` | Add model-matched prompt contributions. |
265
+ | `prompt_cache` | Use provider prompt caching. |
266
+ | `prompt_sections` | Set default prompt sections. |
329
267
 
330
268
  ## Contributing
331
269
 
@@ -9,7 +9,7 @@ module TurnKit
9
9
  configure_from_environment
10
10
 
11
11
  chat = ::RubyLLM.chat(model: model)
12
- chat.with_instructions(instructions) if instructions && !instructions.empty?
12
+ add_instructions(chat, instructions, model: model)
13
13
  chat.with_temperature(temperature) if temperature
14
14
  Array(tools).each { |tool| chat.with_tool(ruby_llm_tool(tool)) }
15
15
  Array(messages).each { |message| add_message(chat, message) }
@@ -55,6 +55,37 @@ module TurnKit
55
55
  )
56
56
  end
57
57
 
58
+ def add_instructions(chat, instructions, model:)
59
+ return if instructions.nil? || instructions.empty?
60
+
61
+ if prompt_cache_enabled? && anthropic_model?(model) && instructions.include?(SystemPrompt::CACHE_BOUNDARY)
62
+ stable, dynamic = SystemPrompt.split_cache_boundary(instructions)
63
+ add_system_message(chat, stable, cache: true)
64
+ add_system_message(chat, dynamic, cache: false)
65
+ else
66
+ chat.with_instructions(instructions)
67
+ end
68
+ end
69
+
70
+ def add_system_message(chat, content, cache: false)
71
+ content = content.to_s.strip
72
+ return if content.empty?
73
+
74
+ if cache
75
+ content = ::RubyLLM::Providers::Anthropic::Content.new(content, cache: true)
76
+ end
77
+
78
+ chat.add_message(role: :system, content: content)
79
+ end
80
+
81
+ def prompt_cache_enabled?
82
+ TurnKit.prompt_cache != :off
83
+ end
84
+
85
+ def anthropic_model?(model)
86
+ model.to_s.start_with?("claude")
87
+ end
88
+
58
89
  def ruby_llm_tool_calls(tool_calls)
59
90
  return nil if tool_calls.nil? || tool_calls.empty?
60
91
 
@@ -88,9 +119,10 @@ module TurnKit
88
119
  ToolCall.new(id: call.id, name: call.name, arguments: call.arguments)
89
120
  end
90
121
  usage = Usage.new(
91
- input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : 0,
92
- output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : 0,
93
- cached_tokens: response.respond_to?(:cached_tokens) ? response.cached_tokens : 0
122
+ input_tokens: token_value(response, :input_tokens),
123
+ output_tokens: token_value(response, :output_tokens),
124
+ cached_tokens: token_value(response, :cached_tokens),
125
+ cache_write_tokens: token_value(response, :cache_creation_tokens)
94
126
  )
95
127
  Result.new(
96
128
  text: response.respond_to?(:content) ? response.content.to_s : response.to_s,
@@ -99,6 +131,10 @@ module TurnKit
99
131
  model: response.respond_to?(:model_id) ? response.model_id : model
100
132
  )
101
133
  end
134
+
135
+ def token_value(response, method)
136
+ response.respond_to?(method) ? response.public_send(method).to_i : 0
137
+ end
102
138
  end
103
139
  end
104
140
  end
data/lib/turnkit/turn.rb CHANGED
@@ -123,9 +123,12 @@ module TurnKit
123
123
  "input_tokens" => current["input_tokens"].to_i + usage.input_tokens,
124
124
  "output_tokens" => current["output_tokens"].to_i + usage.output_tokens,
125
125
  "cached_tokens" => current["cached_tokens"].to_i + usage.cached_tokens,
126
+ "cache_write_tokens" => current["cache_write_tokens"].to_i + usage.cache_write_tokens,
126
127
  "total_tokens" => current["total_tokens"].to_i + usage.total_tokens
127
128
  }
128
- update!(usage: totals, heartbeat_at: Clock.now)
129
+ attributes = { usage: totals, heartbeat_at: Clock.now }
130
+ attributes[:cost] = @record["cost"].to_f + usage.cost.to_f if usage.cost
131
+ update!(attributes)
129
132
  end
130
133
 
131
134
  def update!(attributes)
data/lib/turnkit/usage.rb CHANGED
@@ -2,17 +2,18 @@
2
2
 
3
3
  module TurnKit
4
4
  class Usage
5
- attr_reader :input_tokens, :output_tokens, :cached_tokens, :cost
5
+ attr_reader :input_tokens, :output_tokens, :cached_tokens, :cache_write_tokens, :cost
6
6
 
7
- def initialize(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cost: nil)
7
+ def initialize(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cache_write_tokens: 0, cost: nil)
8
8
  @input_tokens = input_tokens.to_i
9
9
  @output_tokens = output_tokens.to_i
10
10
  @cached_tokens = cached_tokens.to_i
11
+ @cache_write_tokens = cache_write_tokens.to_i
11
12
  @cost = cost
12
13
  end
13
14
 
14
15
  def total_tokens
15
- input_tokens + output_tokens + cached_tokens
16
+ input_tokens + output_tokens + cached_tokens + cache_write_tokens
16
17
  end
17
18
 
18
19
  def to_h
@@ -20,6 +21,7 @@ module TurnKit
20
21
  "input_tokens" => input_tokens,
21
22
  "output_tokens" => output_tokens,
22
23
  "cached_tokens" => cached_tokens,
24
+ "cache_write_tokens" => cache_write_tokens,
23
25
  "total_tokens" => total_tokens,
24
26
  "cost" => cost
25
27
  }.compact
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.2.2"
4
+ VERSION = "0.2.3"
5
5
  end
data/lib/turnkit.rb CHANGED
@@ -41,7 +41,7 @@ module TurnKit
41
41
  class << self
42
42
  attr_accessor :default_model, :client, :store, :logger
43
43
  attr_accessor :max_iterations, :timeout, :max_depth, :max_tool_executions
44
- attr_accessor :cost_limit
44
+ attr_accessor :cost_limit, :prompt_cache
45
45
  attr_accessor :prompt_sections, :prompt_behavior, :available_skills
46
46
  attr_accessor :prompt_data_max_chars, :context_contributors
47
47
  attr_accessor :system_prompt_contributors, :model_prompt_contributors
@@ -56,6 +56,7 @@ module TurnKit
56
56
  self.timeout = 300
57
57
  self.max_depth = 3
58
58
  self.max_tool_executions = 100
59
+ self.prompt_cache = :auto
59
60
  self.prompt_sections = SystemPrompt::DEFAULT_SECTIONS.dup
60
61
  self.prompt_data_max_chars = 20_000
61
62
  self.available_skills = []
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turnkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-05 00:00:00.000000000 Z
11
+ date: 2026-06-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm