turnkit 0.5.0 → 0.7.0

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +198 -5
  4. data/UPGRADE.md +57 -0
  5. data/lib/generators/turnkit/install/templates/create_turnkit_tables.rb +30 -0
  6. data/lib/generators/turnkit/install/templates/delivery.rb +7 -0
  7. data/lib/generators/turnkit/install/templates/initializer.rb +5 -0
  8. data/lib/generators/turnkit/install/templates/wait.rb +7 -0
  9. data/lib/generators/turnkit/install_generator.rb +2 -0
  10. data/lib/generators/turnkit/upgrade/templates/add_turnkit_durable_orchestration.rb +36 -0
  11. data/lib/generators/turnkit/upgrade_generator.rb +34 -0
  12. data/lib/turnkit/active_record_store.rb +124 -14
  13. data/lib/turnkit/adapters/ruby_llm.rb +107 -21
  14. data/lib/turnkit/agent.rb +26 -20
  15. data/lib/turnkit/authorization.rb +17 -0
  16. data/lib/turnkit/background.rb +281 -0
  17. data/lib/turnkit/budget.rb +4 -3
  18. data/lib/turnkit/client.rb +3 -1
  19. data/lib/turnkit/conversation.rb +54 -1
  20. data/lib/turnkit/coordination_tools.rb +67 -0
  21. data/lib/turnkit/cost.rb +7 -7
  22. data/lib/turnkit/error.rb +3 -0
  23. data/lib/turnkit/execution_store.rb +30 -0
  24. data/lib/turnkit/id.rb +1 -0
  25. data/lib/turnkit/image_tool.rb +10 -0
  26. data/lib/turnkit/job.rb +21 -0
  27. data/lib/turnkit/memory_store.rb +113 -20
  28. data/lib/turnkit/message_projection.rb +4 -1
  29. data/lib/turnkit/reconciliation.rb +13 -10
  30. data/lib/turnkit/record.rb +36 -3
  31. data/lib/turnkit/run.rb +28 -0
  32. data/lib/turnkit/skill.rb +5 -4
  33. data/lib/turnkit/specialists.rb +254 -0
  34. data/lib/turnkit/store.rb +38 -9
  35. data/lib/turnkit/sub_agent_tool.rb +23 -7
  36. data/lib/turnkit/system_prompt.rb +7 -7
  37. data/lib/turnkit/tool.rb +11 -0
  38. data/lib/turnkit/tool_runner.rb +109 -45
  39. data/lib/turnkit/turn.rb +238 -61
  40. data/lib/turnkit/turn_controls.rb +136 -0
  41. data/lib/turnkit/version.rb +1 -1
  42. data/lib/turnkit.rb +31 -0
  43. metadata +16 -5
@@ -9,11 +9,34 @@ module TurnKit
9
9
  # persistence records. Turnkit::Conversation (a database row) is not
10
10
  # TurnKit::Conversation (the domain object).
11
11
  class ActiveRecordStore < Store
12
- def initialize(conversation_class: "Turnkit::Conversation", turn_class: "Turnkit::Turn", message_class: "Turnkit::Message", tool_execution_class: "Turnkit::ToolExecution")
12
+ def initialize(conversation_class: "Turnkit::Conversation", turn_class: "Turnkit::Turn", message_class: "Turnkit::Message", tool_execution_class: "Turnkit::ToolExecution", delivery_class: "Turnkit::Delivery", wait_class: "Turnkit::Wait")
13
13
  @conversation_class_name = conversation_class
14
14
  @turn_class_name = turn_class
15
15
  @message_class_name = message_class
16
16
  @tool_execution_class_name = tool_execution_class
17
+ @delivery_class_name = delivery_class
18
+ @wait_class_name = wait_class
19
+ end
20
+
21
+ def atomic(conversation_id)
22
+ conversation_class.connection_pool.with_connection do
23
+ conversation_class.transaction do
24
+ conversation_class.lock.find_by!(uid: conversation_id)
25
+ yield
26
+ end
27
+ end
28
+ end
29
+
30
+ # Wait-graph mutations use one database-wide transaction lock. PostgreSQL
31
+ # advisory xact locks serialize opposite edges even when roots differ.
32
+ def atomic_graph
33
+ conversation_class.connection_pool.with_connection do |connection|
34
+ conversation_class.transaction do
35
+ raise ConfigError, "atomic wait graphs require PostgreSQL" unless connection.adapter_name.match?(/postg/i)
36
+ connection.execute("SELECT pg_advisory_xact_lock(1414876747)")
37
+ yield
38
+ end
39
+ end
17
40
  end
18
41
 
19
42
  def create_conversation(attributes)
@@ -85,6 +108,8 @@ module TurnKit
85
108
  cost: attrs["cost"],
86
109
  error: attrs["error"],
87
110
  output_text: attrs["output_text"],
111
+ submitted_at: attrs["submitted_at"],
112
+ claim_token: attrs["claim_token"],
88
113
  started_at: attrs["started_at"],
89
114
  heartbeat_at: attrs["heartbeat_at"],
90
115
  completed_at: attrs["completed_at"]
@@ -123,6 +148,85 @@ module TurnKit
123
148
  scope.order(:created_at, :uid).map { |record| turn_hash(record) }
124
149
  end
125
150
 
151
+ def list_submitted_turns(limit: nil)
152
+ scope = turn_class.where.not(submitted_at: nil).order(:created_at, :uid)
153
+ scope = scope.limit(limit) if limit
154
+ scope.map { |record| turn_hash(record) }
155
+ end
156
+
157
+ def list_actionable_turns(limit:)
158
+ turn_class.where.not(submitted_at: nil).where(status: %w[pending waiting running]).order(:updated_at, :uid).limit(limit).map { |record| turn_hash(record) }
159
+ end
160
+
161
+ def list_stale_inline_turns(before:, limit:)
162
+ turn_class.where(submitted_at: nil, status: %w[pending running])
163
+ .where("COALESCE(heartbeat_at, started_at, created_at) < ?", before)
164
+ .order(:updated_at, :uid).limit(limit).map { |record| turn_hash(record) }
165
+ end
166
+
167
+ def busy_conversation?(id, include_pending: true)
168
+ turns = turn_class.where(conversation_uid: id)
169
+ active = turns.where(status: %w[running waiting paused])
170
+ active = active.or(turns.where(status: "pending").where.not(submitted_at: nil)) if include_pending
171
+ active.exists?
172
+ end
173
+
174
+ def next_delivery_trigger(id)
175
+ consumed = turn_class.where(conversation_uid: id).where.not(status: "pending", submitted_at: nil).maximum(:context_message_sequence).to_i
176
+ delivered_ids = delivery_class.where(destination_conversation_uid: id).where.not(message_uid: nil).select(:message_uid)
177
+ message = message_class.where(conversation_uid: id, uid: delivered_ids).where("sequence > ?", consumed).order(sequence: :desc).first
178
+ message_hash(message) if message
179
+ end
180
+
181
+ def create_delivery(attributes)
182
+ attrs = Record.delivery(attributes)
183
+ record = delivery_class.create_or_find_by!(key: attrs.fetch("key")) do |delivery|
184
+ delivery.uid = attrs.fetch("id")
185
+ delivery.source_conversation_uid = attrs.fetch("source_conversation_id")
186
+ delivery.destination_conversation_uid = attrs.fetch("destination_conversation_id")
187
+ delivery.source_turn_uid = attrs["source_turn_id"]
188
+ delivery.payload = attrs.fetch("payload")
189
+ delivery.message_uid = attrs["message_id"]
190
+ delivery.delivered_at = attrs["delivered_at"]
191
+ delivery.created_at = attrs.fetch("created_at")
192
+ end
193
+ existing = delivery_hash(record)
194
+ Record.assert_delivery_retry!(existing, attrs)
195
+ existing
196
+ end
197
+
198
+ def load_delivery(id)
199
+ delivery_hash(delivery_class.find_by!(uid: id))
200
+ end
201
+
202
+ def update_delivery(id, attributes)
203
+ record = delivery_class.find_by!(uid: id)
204
+ record.update!(Record.delivery_update(attributes).transform_keys { |key| key == "message_id" ? "message_uid" : key })
205
+ delivery_hash(record)
206
+ end
207
+
208
+ def list_deliveries(source_conversation_id: nil, destination_conversation_id: nil, pending: false, limit: nil)
209
+ scope = delivery_class.all
210
+ scope = scope.where(source_conversation_uid: source_conversation_id) if source_conversation_id
211
+ scope = scope.where(destination_conversation_uid: destination_conversation_id) if destination_conversation_id
212
+ scope = scope.where(delivered_at: nil) if pending
213
+ scope = scope.order(:created_at, :uid)
214
+ scope = scope.limit(limit) if limit
215
+ scope.map { |record| delivery_hash(record) }
216
+ end
217
+
218
+ def create_wait(turn_id:, target_turn_id:)
219
+ record = wait_class.create_or_find_by!(turn_uid: turn_id, target_turn_uid: target_turn_id)
220
+ wait_hash(record)
221
+ end
222
+
223
+ def list_waits(turn_id: nil, target_turn_id: nil)
224
+ scope = wait_class.all
225
+ scope = scope.where(turn_uid: turn_id) if turn_id
226
+ scope = scope.where(target_turn_uid: target_turn_id) if target_turn_id
227
+ scope.order(:id).map { |record| wait_hash(record) }
228
+ end
229
+
126
230
  def create_tool_execution(attributes)
127
231
  attrs = Record.tool_execution(attributes)
128
232
  record = tool_execution_class.create!(
@@ -156,23 +260,13 @@ module TurnKit
156
260
  tool_execution_class.where(turn_uid: turn_id).order(:created_at, :uid).map { |record| tool_execution_hash(record) }
157
261
  end
158
262
 
159
- def reconcile_stale_turns(before:)
160
- scope = turn_class.where(status: %w[pending running]).where("COALESCE(heartbeat_at, started_at, created_at) < ?", before)
161
- scope.filter_map do |record|
162
- now = Clock.now
163
- affected = turn_class
164
- .where(id: record.id, status: %w[pending running])
165
- .where("COALESCE(heartbeat_at, started_at, created_at) < ?", before)
166
- .update_all(status: "stale", completed_at: now, updated_at: now)
167
- turn_hash(record.reload) if affected == 1
168
- end
169
- end
170
-
171
263
  private
172
264
  def conversation_class = constantize(@conversation_class_name)
173
265
  def turn_class = constantize(@turn_class_name)
174
266
  def message_class = constantize(@message_class_name)
175
267
  def tool_execution_class = constantize(@tool_execution_class_name)
268
+ def delivery_class = constantize(@delivery_class_name)
269
+ def wait_class = constantize(@wait_class_name)
176
270
 
177
271
  def constantize(name)
178
272
  name.to_s.split("::").inject(Object) { |mod, part| mod.const_get(part) }
@@ -192,7 +286,9 @@ module TurnKit
192
286
  end
193
287
 
194
288
  def conversation_hash(record)
195
- { "id" => record.uid, "agent_name" => record.agent_name, "model" => record.model, "metadata" => record.metadata || {}, "created_at" => record.created_at, "updated_at" => record.updated_at }
289
+ { "id" => record.uid, "agent_name" => record.agent_name, "model" => record.model,
290
+ "subject" => record.subject_type && { "type" => record.subject_type, "id" => record.subject_id },
291
+ "metadata" => record.metadata || {}, "created_at" => record.created_at, "updated_at" => record.updated_at }
196
292
  end
197
293
 
198
294
  def turn_hash(record)
@@ -202,6 +298,7 @@ module TurnKit
202
298
  "root_turn_id" => record.root_turn_uid, "context_message_sequence" => record.context_message_sequence,
203
299
  "status" => record.status, "model" => record.model, "options" => record.options || {}, "usage" => record.usage || {},
204
300
  "cost" => record.cost, "error" => record.error, "output_text" => record.output_text,
301
+ "submitted_at" => record.submitted_at, "claim_token" => record.claim_token,
205
302
  "started_at" => record.started_at, "heartbeat_at" => record.heartbeat_at, "completed_at" => record.completed_at,
206
303
  "created_at" => record.created_at, "updated_at" => record.updated_at
207
304
  }
@@ -209,6 +306,19 @@ module TurnKit
209
306
  attrs
210
307
  end
211
308
 
309
+ def delivery_hash(record)
310
+ {
311
+ "id" => record.uid, "source_conversation_id" => record.source_conversation_uid,
312
+ "destination_conversation_id" => record.destination_conversation_uid, "source_turn_id" => record.source_turn_uid,
313
+ "key" => record.key, "payload" => record.payload || {}, "message_id" => record.message_uid,
314
+ "delivered_at" => record.delivered_at, "created_at" => record.created_at
315
+ }
316
+ end
317
+
318
+ def wait_hash(record)
319
+ { "turn_id" => record.turn_uid, "target_turn_id" => record.target_turn_uid }
320
+ end
321
+
212
322
  def turn_has_attribute?(name)
213
323
  turn_class.respond_to?(:attribute_names) && turn_class.attribute_names.include?(name)
214
324
  end
@@ -10,9 +10,16 @@ module TurnKit
10
10
  openrouter: "OPENROUTER_API_KEY"
11
11
  }.freeze
12
12
 
13
+ def initialize(protocol: nil)
14
+ @protocol = protocol
15
+ end
16
+
13
17
  def validate!(model:)
14
18
  ensure_ruby_llm!
15
19
  raise ModelAccessError, "model is required" if model.to_s.empty?
20
+ if @protocol && !::RubyLLM::Chat.method_defined?(:generate)
21
+ raise ConfigError, "protocol selection requires RubyLLM 2.0 (currently 2.0.0.rc2)"
22
+ end
16
23
 
17
24
  configure_from_environment
18
25
  provider = provider_for(model)
@@ -27,16 +34,27 @@ module TurnKit
27
34
  ensure_ruby_llm!
28
35
  configure_from_environment
29
36
 
30
- chat = ::RubyLLM.chat(model: model)
37
+ validate!(model: model) if @protocol
38
+ chat = ::RubyLLM.chat(**{ model: model, protocol: @protocol }.compact)
39
+ chat.with_provider_options(metadata: metadata.transform_values(&:to_s)) if @protocol == :responses && metadata
31
40
  add_instructions(chat, instructions, dynamic_instructions, model: model)
32
41
  chat.with_temperature(temperature) if temperature
33
42
  apply_thinking(chat, thinking)
34
43
  chat.with_schema(normalize_schema(output_schema)) if output_schema
35
- Array(tools).each { |tool| chat.with_tool(ruby_llm_tool(tool)) }
36
- Array(messages).each { |message| add_message(chat, message) }
44
+ Array(tools).each do |tool|
45
+ chat.respond_to?(:with_tool) ? chat.with_tool(ruby_llm_tool(tool)) : chat.with_tools(ruby_llm_tool(tool))
46
+ end
47
+ tool_names = {}
48
+ Array(messages).each { |message| add_message(chat, message, provider: chat.model.provider, tool_names: tool_names) }
37
49
 
38
50
  response = complete_without_tool_execution(chat)
39
51
  normalize_response(response, model: model)
52
+ rescue ConfigError
53
+ raise
54
+ rescue ::RubyLLM::Error, ::RubyLLM::ModelNotFoundError => error
55
+ # Provider failures (after RubyLLM's own retries) are task failures,
56
+ # not worker crashes to be replayed by durable reconciliation.
57
+ raise ModelError, "#{error.class}: #{error.message}"
40
58
  end
41
59
 
42
60
  def paint(prompt:, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: nil, on_event: nil)
@@ -50,25 +68,44 @@ module TurnKit
50
68
  size: size || "1024x1024",
51
69
  with: input_images,
52
70
  mask: mask,
53
- params: params || {}
71
+ **{ (::RubyLLM::Chat.method_defined?(:generate) ? :provider_options : :params) => params || {} }
54
72
  )
55
73
  normalize_image_response(image, model: model, provider: provider, params: { "size" => size || "1024x1024" }.merge(params || {}), metadata: metadata)
74
+ rescue ConfigError
75
+ raise
76
+ rescue ::RubyLLM::Error, ::RubyLLM::ModelNotFoundError => error
77
+ raise ModelError, "#{error.class}: #{error.message}"
56
78
  end
57
79
 
58
80
  def view_media(media:, objective:, model:, provider: nil, output_schema: nil, params: {}, metadata: nil, on_event: nil)
59
81
  ensure_ruby_llm!
60
82
  configure_from_environment
61
83
  media_input = MediaInput.wrap(media)
62
- content = ::RubyLLM::Content.new(objective.to_s)
63
- content.add_attachment(media_input.attachment_source, filename: media_input.filename)
64
84
 
65
85
  chat = ::RubyLLM.chat(model: model)
66
86
  chat.with_schema(normalize_schema(output_schema)) if output_schema
67
- chat.with_params(**params) if params && !params.empty?
68
- chat.add_message(role: :user, content: content)
87
+ if params && !params.empty?
88
+ if ::RubyLLM::Chat.method_defined?(:generate)
89
+ chat.with_provider_options(**params)
90
+ else
91
+ chat.with_params(**params)
92
+ end
93
+ end
94
+ if ::RubyLLM::Chat.method_defined?(:generate)
95
+ attachment = ::RubyLLM::Attachment.new(media_input.attachment_source, filename: media_input.filename)
96
+ chat.add_message(role: :user, content: objective.to_s, attachments: [attachment])
97
+ else
98
+ content = ::RubyLLM::Content.new(objective.to_s)
99
+ content.add_attachment(media_input.attachment_source, filename: media_input.filename)
100
+ chat.add_message(role: :user, content: content)
101
+ end
69
102
 
70
103
  response = complete_without_tool_execution(chat)
71
104
  normalize_media_analysis_response(response, media: media_input, model: model, provider: provider, params: params || {}, metadata: metadata)
105
+ rescue ConfigError
106
+ raise
107
+ rescue ::RubyLLM::Error, ::RubyLLM::ModelNotFoundError => error
108
+ raise ModelError, "#{error.class}: #{error.message}"
72
109
  end
73
110
 
74
111
  private
@@ -119,12 +156,10 @@ module TurnKit
119
156
  end
120
157
  end
121
158
 
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.
159
+ # 2.0's public generate requests one completion without executing tools.
160
+ # 1.16 needs its private provider_completion, guarded by a canary test.
127
161
  def complete_without_tool_execution(chat)
162
+ return chat.generate if chat.respond_to?(:generate)
128
163
  unless chat.respond_to?(:provider_completion, true)
129
164
  raise ConfigError, "TurnKit::Adapters::RubyLLM requires ruby_llm >= 1.16 (RubyLLM::Chat#provider_completion not found)"
130
165
  end
@@ -132,15 +167,31 @@ module TurnKit
132
167
  chat.send(:provider_completion)
133
168
  end
134
169
 
135
- def add_message(chat, message)
170
+ def add_message(chat, message, provider: nil, tool_names: {})
136
171
  role = (message[:role] || message["role"]).to_sym
137
172
  content = message[:content] || message["content"] || ""
173
+ kind = { "openai" => "openai_responses", "anthropic" => "anthropic", "gemini" => "gemini" }[provider.to_s]
174
+ replay = Array(message[:provider_parts] || message["provider_parts"]).find { |part| part["kind"] == kind } if kind
175
+ calls = ruby_llm_tool_calls(message[:tool_calls] || message["tool_calls"])
176
+ raw_content = replay&.fetch("data")
177
+ if raw_content && %w[anthropic gemini].include?(kind) && !::RubyLLM::Chat.method_defined?(:generate)
178
+ content = ::RubyLLM::Content::Raw.new(raw_content)
179
+ raw_content = nil
180
+ if kind == "gemini"
181
+ # 1.16 appends normalized calls after Raw content. Omit those
182
+ # duplicates and supply names for its positional function results.
183
+ calls&.each { |id, call| tool_names[id] = call.name }
184
+ calls = nil
185
+ end
186
+ end
187
+ call_id = message[:tool_call_id] || message["tool_call_id"]
138
188
  chat.add_message(
139
189
  {
140
190
  role: role,
141
191
  content: content,
142
- tool_calls: ruby_llm_tool_calls(message[:tool_calls] || message["tool_calls"]),
143
- tool_call_id: message[:tool_call_id] || message["tool_call_id"]
192
+ raw_content: raw_content,
193
+ tool_calls: calls,
194
+ tool_call_id: tool_names.fetch(call_id, call_id)
144
195
  }.compact
145
196
  )
146
197
  end
@@ -162,6 +213,10 @@ module TurnKit
162
213
  content = content.to_s.strip
163
214
  return if content.empty?
164
215
 
216
+ if ::RubyLLM::Chat.method_defined?(:generate)
217
+ chat.add_message(role: :system, content: content, cache_until_here: cache)
218
+ return
219
+ end
165
220
  if cache
166
221
  content = ::RubyLLM::Providers::Anthropic::Content.new(content, cache: true)
167
222
  end
@@ -193,7 +248,7 @@ module TurnKit
193
248
  Class.new(::RubyLLM::Tool) do
194
249
  define_singleton_method(:name) { tool.tool_name }
195
250
  description tool.description
196
- params tool.input_schema
251
+ respond_to?(:params) ? params(tool.input_schema) : parameters(tool.input_schema)
197
252
 
198
253
  define_method(:execute) do |**arguments|
199
254
  raise ToolError, "tools must be executed by TurnKit turns, not the RubyLLM adapter"
@@ -202,6 +257,10 @@ module TurnKit
202
257
  end
203
258
 
204
259
  def normalize_response(response, model:)
260
+ raw = response.raw.body if response.respond_to?(:raw) && response.raw.respond_to?(:body)
261
+ if raw.is_a?(Hash) && raw["object"] == "response" && raw["status"] != "completed"
262
+ raise ModelError, "OpenAI Responses #{raw['status']}: #{raw.dig('incomplete_details', 'reason')}"
263
+ end
205
264
  tool_calls = Array(response.respond_to?(:tool_calls) ? response.tool_calls&.values : []).map do |call|
206
265
  ToolCall.new(id: call.id, name: call.name, arguments: call.arguments)
207
266
  end
@@ -219,7 +278,7 @@ module TurnKit
219
278
  output_data: response_data(response),
220
279
  tool_calls: tool_calls,
221
280
  usage: usage,
222
- model: response.respond_to?(:model_id) ? response.model_id : model
281
+ model: response_model(response, model)
223
282
  )
224
283
  end
225
284
 
@@ -234,6 +293,14 @@ module TurnKit
234
293
  text = content.to_s
235
294
  text.empty? ? [] : [ { "type" => "text", "text" => text } ]
236
295
  end.compact
296
+ raw = response.raw.body if response.respond_to?(:raw) && response.raw.respond_to?(:body)
297
+ if raw.is_a?(Hash) && raw["object"] == "response"
298
+ parts << { "type" => "provider", "kind" => "openai_responses", "data" => raw.fetch("output") }
299
+ elsif raw.is_a?(Hash) && raw["type"] == "message" && raw["role"] == "assistant"
300
+ parts << { "type" => "provider", "kind" => "anthropic", "data" => raw.fetch("content") }
301
+ elsif raw.is_a?(Hash) && raw.dig("candidates", 0, "content", "parts")
302
+ parts << { "type" => "provider", "kind" => "gemini", "data" => raw.dig("candidates", 0, "content", "parts") }
303
+ end
237
304
  parts + Array(tool_calls).map { |call| { "type" => "tool_call", "id" => call.id, "name" => call.name, "arguments" => call.arguments } }
238
305
  end
239
306
 
@@ -267,7 +334,25 @@ module TurnKit
267
334
  end
268
335
 
269
336
  def token_value(response, method)
270
- response.respond_to?(method) ? response.public_send(method).to_i : 0
337
+ if response.respond_to?(method)
338
+ value = response.public_send(method).to_i
339
+ raw = response.raw.body if response.respond_to?(:raw) && response.raw.respond_to?(:body)
340
+ native = raw.is_a?(Hash) && (raw["candidates"] || raw["type"] == "message")
341
+ # Native 1.16 providers also include thinking in their output count.
342
+ return native && method == :output_tokens ? value - thinking_token_value(response) : value
343
+ end
344
+ return 0 unless response.respond_to?(:tokens)
345
+
346
+ key = { input_tokens: :input, output_tokens: :output, cached_tokens: :cache_read,
347
+ cache_creation_tokens: :cache_write, thinking_tokens: :thinking, reasoning_tokens: :thinking }.fetch(method)
348
+ value = response.tokens.public_send(key).to_i
349
+ # RubyLLM 2.0 includes thinking in output; TurnKit buckets are additive.
350
+ method == :output_tokens ? value - response.tokens.thinking.to_i : value
351
+ end
352
+
353
+ def response_model(response, fallback)
354
+ return response.model if response.respond_to?(:model)
355
+ response.respond_to?(:model_id) ? response.model_id : fallback
271
356
  end
272
357
 
273
358
  def thinking_token_value(response)
@@ -291,7 +376,7 @@ module TurnKit
291
376
  data: image.respond_to?(:data) ? image.data : nil,
292
377
  mime_type: image.respond_to?(:mime_type) ? image.mime_type : nil,
293
378
  revised_prompt: image.respond_to?(:revised_prompt) ? image.revised_prompt : nil,
294
- model: image.respond_to?(:model_id) ? image.model_id : model,
379
+ model: response_model(image, model),
295
380
  provider: provider&.to_s,
296
381
  usage: usage,
297
382
  params: params,
@@ -313,7 +398,7 @@ module TurnKit
313
398
  part = MediaAnalysisResult.new(
314
399
  text: response_text(response),
315
400
  data: response_data(response),
316
- model: response.respond_to?(:model_id) ? response.model_id : model,
401
+ model: response_model(response, model),
317
402
  provider: provider&.to_s,
318
403
  usage: usage,
319
404
  params: params,
@@ -325,6 +410,7 @@ module TurnKit
325
410
  end
326
411
 
327
412
  def image_usage_value(image, key)
413
+ return image.tokens.public_send(key == "input_tokens" ? :input : :output).to_i if image.respond_to?(:tokens)
328
414
  usage = image.respond_to?(:usage) ? image.usage || {} : {}
329
415
  (usage[key] || usage[key.to_sym]).to_i
330
416
  end
data/lib/turnkit/agent.rb CHANGED
@@ -24,21 +24,21 @@ module TurnKit
24
24
  attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
25
25
  attr_reader :client, :store, :max_iterations, :timeout, :max_spend, :max_depth, :max_tool_executions, :max_tool_executions_by_name
26
26
  attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :input_schema, :on_event
27
- attr_reader :output_policy, :output_policy_mode, :output_policy_model, :output_retries
27
+ attr_reader :output_policy, :output_policy_mode, :output_policy_model, :output_retries, :context_contributors
28
28
 
29
29
  def initialize(name:, description: "", model: nil, instructions: "", orchestrator: false, tools: [], skills: [], available_skills: [], sub_agents: [],
30
30
  system_prompt: nil, prompt_sections: nil, prompt_mode: nil, client: nil, store: nil,
31
31
  max_iterations: nil, timeout: nil, max_spend: nil, max_depth: nil, max_tool_executions: nil, max_tool_executions_by_name: nil, thinking: nil, compaction: nil,
32
- output_schema: nil, input_schema: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, output_retries: 0, on_event: nil)
32
+ output_schema: nil, input_schema: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, output_retries: 0, on_event: nil, context_contributors: [], inherit_globals: true)
33
33
  @name = name.to_s
34
34
  @description = description.to_s
35
35
  @model = model
36
36
  @orchestrator = orchestrator ? true : false
37
37
  @instructions = compose_instructions(instructions)
38
- @tools = Array(tools)
39
- @skills = Array(skills)
40
- @available_skills = Array(available_skills)
41
- @sub_agents = Array(sub_agents)
38
+ @tools = Array(tools).dup.freeze
39
+ @skills = Array(skills).dup.freeze
40
+ @available_skills = ((inherit_globals ? Array(TurnKit.available_skills) : []) + Array(available_skills)).uniq { |skill| skill.key }.freeze
41
+ @sub_agents = Array(sub_agents).dup.freeze
42
42
  @system_prompt = system_prompt
43
43
  @prompt_sections = prompt_sections
44
44
  @prompt_mode = prompt_mode&.to_sym || (:task if @orchestrator)
@@ -59,6 +59,7 @@ module TurnKit
59
59
  @output_policy_mode = normalize_output_policy_mode(output_policy_mode)
60
60
  @output_retries = Integer(output_retries || 0)
61
61
  @on_event = on_event
62
+ @context_contributors = ((inherit_globals ? Array(TurnKit.context_contributors) : []) + Array(context_contributors)).freeze
62
63
  raise ArgumentError, "name is required" if @name.empty?
63
64
  validate_tools!
64
65
  end
@@ -78,32 +79,35 @@ module TurnKit
78
79
  attrs.slice(:effort, :budget).compact
79
80
  end
80
81
 
81
- def conversation(model: nil, subject: nil, metadata: {})
82
+ def conversation(model: nil, subject: nil, metadata: {}, context: {}, principal: nil)
82
83
  store = effective_store
84
+ context = JSON.parse(JSON.generate(context))
85
+ metadata = metadata.merge("principal" => principal) unless principal.nil?
86
+ metadata = metadata.merge("turnkit_subject_prompt" => subject.to_prompt.to_s) if subject.respond_to?(:to_prompt)
83
87
  record = store.create_conversation(
84
88
  "agent_name" => name,
85
89
  "model" => model || effective_model,
86
90
  "subject" => subject,
87
- "metadata" => metadata
91
+ "metadata" => metadata.merge("turnkit_context" => context)
88
92
  )
89
- Conversation.new(agent: self, record: record, store: store, model: model || effective_model, subject: subject, metadata: metadata)
93
+ Conversation.new(agent: self, record: record, store: store, model: model || effective_model, subject: subject, metadata: metadata.merge("turnkit_context" => context))
90
94
  end
91
95
 
92
96
  def orchestrator?
93
97
  @orchestrator
94
98
  end
95
99
 
96
- def run(task, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, prompt_mode: :task, **options)
100
+ def run(task, input: nil, async: false, subject: nil, metadata: {}, context: {}, principal: nil, parent_run: nil, root_turn_id: nil, prompt_mode: :task, **options)
97
101
  raise ArgumentError, "task is required" if task.to_s.empty?
98
102
  SchemaCheck.validate!(input, input_schema, error_class: InputError, label: "input") if input_schema
99
103
 
100
- conversation = self.conversation(subject: subject, metadata: metadata)
104
+ conversation = self.conversation(subject: subject, metadata: metadata, context: context, principal: principal)
101
105
  message = conversation.say(task_message(task, input), metadata: { "source" => "application", "task" => true })
102
106
  turn = conversation.build_turn(
103
107
  trigger_message_id: message.id,
104
108
  root_turn_id: root_turn_id || parent_run_root_turn_id(parent_run),
105
109
  prompt_mode: prompt_mode,
106
- **options
110
+ principal: principal, context: context, **options
107
111
  )
108
112
  run = Run.new(turn)
109
113
  async ? run : run.run!
@@ -137,10 +141,11 @@ module TurnKit
137
141
  store || TurnKit.store
138
142
  end
139
143
 
140
- def effective_tools
141
- configured = tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
142
- skills = effective_available_skills
143
- skills.empty? ? configured : configured + [ LoadSkillTool.for(skills) ]
144
+ def effective_tools(turn: nil)
145
+ loaded = turn ? turn.tool_executions.select { |execution| execution.tool_name == "load_skill" && execution.completed? }.map { |execution| execution.result.fetch("key") } : []
146
+ active_skills = skills + available_skills.select { |skill| loaded.include?(skill.key) }
147
+ configured = tools + active_skills.flat_map(&:tools) + sub_agents.map { |agent| SubAgentTool.for(agent) }
148
+ available_skills.empty? ? configured : configured + [ LoadSkillTool.for(available_skills) ]
144
149
  end
145
150
 
146
151
  def effective_on_event
@@ -148,7 +153,7 @@ module TurnKit
148
153
  end
149
154
 
150
155
  def effective_available_skills
151
- (Array(TurnKit.available_skills) + available_skills).uniq { |skill| skill.key }
156
+ available_skills
152
157
  end
153
158
 
154
159
  def effective_prompt_sections
@@ -204,18 +209,19 @@ module TurnKit
204
209
  end
205
210
 
206
211
  def validate_tools!
207
- effective_tools.each do |tool|
212
+ all_tools = effective_tools + available_skills.flat_map(&:tools)
213
+ all_tools.each do |tool|
208
214
  next if tool.is_a?(Class) && tool < Tool
209
215
  next if tool.is_a?(Tool)
210
216
 
211
217
  raise ArgumentError, "tools must be TurnKit::Tool classes or instances"
212
218
  end
213
219
 
214
- names = effective_tools.map(&:tool_name)
220
+ names = all_tools.map(&:tool_name)
215
221
  duplicate = names.find { |name| names.count(name) > 1 }
216
222
  raise ArgumentError, "duplicate tool name: #{duplicate}" if duplicate
217
223
 
218
- effective_tools.each(&:validate_definition!)
224
+ all_tools.each(&:validate_definition!)
219
225
  end
220
226
 
221
227
  def normalize_output_policy(value, model: nil, thinking: nil)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ # Application-owned authorization boundary. A configured policy must return
5
+ # true explicitly; the model never supplies the principal.
6
+ module Authorization
7
+ module_function
8
+
9
+ def authorize!(action, principal:, **resource)
10
+ policy = TurnKit.authorization_policy
11
+ return true unless policy
12
+ allowed = policy.respond_to?(:authorize?) ? policy.authorize?(action, principal: principal, **resource) : policy.call(action, principal: principal, **resource)
13
+ raise AuthorizationError, "not authorized to #{action}" unless allowed == true
14
+ true
15
+ end
16
+ end
17
+ end