turnkit 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8ca23035231ccf58df8389aa2e845bb3e8874b1a23d33a8e8ffb8b793c533fe
4
- data.tar.gz: 1f1bafaf7eb330caa119504846b9960989f116780b5ea364fdbe4e7c47869db2
3
+ metadata.gz: 2a276982d34a5c7685f9512e29f1843942166c67bdb59c94ebc38b727213ffe3
4
+ data.tar.gz: 8ad4edfdc514c24a918441734c4c10294e7d1c45b08b7594f19db7e3294edd61
5
5
  SHA512:
6
- metadata.gz: dbe5569a73d9118776e207567a515642d9fc59923de34b5084499623f36798e9c826832bf35d7f8105b9272252edb6da244faf896f9509e59e571d9db7f9022f
7
- data.tar.gz: 31b2decb3c2336eafd7f943eaff23cf149b88c67067b75c785f9cd71960349a9f89a20da5dfd1d415fb1f7e083f9e061514f359568c4a5b92fc44426a8e05fcd
6
+ metadata.gz: 5bff210317826da7bd796b0271006b76c2ed301dbd9070248529efd8c14e3b2d43414258373f6c709a1496f91004764227ff2453073be0040ac0d5bde4eb3d77
7
+ data.tar.gz: ed8c88ae93a81983f2284e3ed69c018a1e05abd4b2ae081db2160a937c1e4e42fdece546baac10ca8c976e94da54354275f15d80d5dc4cc60cb55e919a78e8d1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 - 2026-09-10
4
+
5
+ - Add destination-oriented `Conversation#post`, durable input/request receipts,
6
+ authorized transcript cursor reads, and cooperative `pause!`, `resume!`, and
7
+ `steer!` controls on turns/runs, including parent-linked subtree controls.
8
+ - Preserve in-flight results while skipping stale tool proposals before steering;
9
+ keep paused conversations closed to automatic wake and retain dependency waits.
10
+ - Enqueue newly eligible joined turns in the same maintenance pass, and serialize
11
+ MemoryStore message insertion with sequence allocation for cursor catchup.
12
+ - Project busy-time deliveries at their receiving turn's context boundary so
13
+ earlier steering cannot override next-turn input or split prior tool exchanges.
14
+ - Support optional RubyLLM 2.0.0.rc2 with `protocol: :responses`, opaque provider
15
+ replay, single-response `generate`, and updated usage/media APIs. Keep RubyLLM
16
+ 1.16 compatibility and the stable development dependency; no separate HTTP
17
+ adapter or tool executor. Add live Astra/xhigh Rails 8.1/Sidekiq validation.
18
+ - Preserve complete native Anthropic/Gemini thinking/tool blocks across durable
19
+ reload, steering and recovery, without exposing opaque state in UI/activity.
20
+ Avoid duplicate raw Gemini calls and double-counted native thinking on 1.16;
21
+ validate Claude Opus 4.8/high and Gemini 3.1 Pro/high on both SDK versions.
22
+ - No migration on the 0.6.0 schema. Add `paused` to application/custom-store status
23
+ handling and upgrade workers together before enabling controls. Existing
24
+ delivery retry payloads remain compatible. See `docs/interactive-research.md`.
25
+
3
26
  ## 0.6.0 - 2026-09-06
4
27
 
5
28
  ### Added
data/README.md CHANGED
@@ -7,6 +7,17 @@
7
7
  Build durable Ruby and Rails agents with conversations, runs, orchestrator agents,
8
8
  tools, skills, output audits, sub-agents, and persistence.
9
9
 
10
+ For interactive long-running work, use `conversation.post(text, key:, principal:)`
11
+ for next-turn input and `turn.steer!(text, key:, principal:)` to revise the active
12
+ plan. `turn.pause!`/`resume!` preserve progress and release background workers;
13
+ use `descendants: :cascade` for a research subtree. See
14
+ [interactive research](docs/interactive-research.md) for exact interruption
15
+ boundaries, durable receipts, approval gates, and Rails integration.
16
+ For GPT-6 Astra tools, opt into the pinned RubyLLM 2 release candidate and
17
+ [Responses protocol](docs/interactive-research.md#gpt-6-astra-and-provider-continuation-state).
18
+ The [live validation app](examples/interactive_validation/README.md) exercises
19
+ these controls with Rails 8.1, PostgreSQL, Sidekiq, and actual Astra/xhigh requests.
20
+
10
21
  ## Installation
11
22
 
12
23
  Add this line to your application's **Gemfile**:
@@ -166,7 +166,7 @@ module TurnKit
166
166
 
167
167
  def busy_conversation?(id, include_pending: true)
168
168
  turns = turn_class.where(conversation_uid: id)
169
- active = turns.where(status: %w[running waiting])
169
+ active = turns.where(status: %w[running waiting paused])
170
170
  active = active.or(turns.where(status: "pending").where.not(submitted_at: nil)) if include_pending
171
171
  active.exists?
172
172
  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,13 +34,18 @@ 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)
@@ -56,7 +68,7 @@ module TurnKit
56
68
  size: size || "1024x1024",
57
69
  with: input_images,
58
70
  mask: mask,
59
- params: params || {}
71
+ **{ (::RubyLLM::Chat.method_defined?(:generate) ? :provider_options : :params) => params || {} }
60
72
  )
61
73
  normalize_image_response(image, model: model, provider: provider, params: { "size" => size || "1024x1024" }.merge(params || {}), metadata: metadata)
62
74
  rescue ConfigError
@@ -69,13 +81,24 @@ module TurnKit
69
81
  ensure_ruby_llm!
70
82
  configure_from_environment
71
83
  media_input = MediaInput.wrap(media)
72
- content = ::RubyLLM::Content.new(objective.to_s)
73
- content.add_attachment(media_input.attachment_source, filename: media_input.filename)
74
84
 
75
85
  chat = ::RubyLLM.chat(model: model)
76
86
  chat.with_schema(normalize_schema(output_schema)) if output_schema
77
- chat.with_params(**params) if params && !params.empty?
78
- 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
79
102
 
80
103
  response = complete_without_tool_execution(chat)
81
104
  normalize_media_analysis_response(response, media: media_input, model: model, provider: provider, params: params || {}, metadata: metadata)
@@ -133,12 +156,10 @@ module TurnKit
133
156
  end
134
157
  end
135
158
 
136
- # RubyLLM has no public API to request a completion without executing
137
- # tool calls, so this depends on the private RubyLLM::Chat#provider_completion
138
- # (added in ruby_llm 1.16). TurnKit must run tools itself (to persist
139
- # executions and enforce budgets). Guarded by a canary test in the
140
- # 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.
141
161
  def complete_without_tool_execution(chat)
162
+ return chat.generate if chat.respond_to?(:generate)
142
163
  unless chat.respond_to?(:provider_completion, true)
143
164
  raise ConfigError, "TurnKit::Adapters::RubyLLM requires ruby_llm >= 1.16 (RubyLLM::Chat#provider_completion not found)"
144
165
  end
@@ -146,15 +167,31 @@ module TurnKit
146
167
  chat.send(:provider_completion)
147
168
  end
148
169
 
149
- def add_message(chat, message)
170
+ def add_message(chat, message, provider: nil, tool_names: {})
150
171
  role = (message[:role] || message["role"]).to_sym
151
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"]
152
188
  chat.add_message(
153
189
  {
154
190
  role: role,
155
191
  content: content,
156
- tool_calls: ruby_llm_tool_calls(message[:tool_calls] || message["tool_calls"]),
157
- 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)
158
195
  }.compact
159
196
  )
160
197
  end
@@ -176,6 +213,10 @@ module TurnKit
176
213
  content = content.to_s.strip
177
214
  return if content.empty?
178
215
 
216
+ if ::RubyLLM::Chat.method_defined?(:generate)
217
+ chat.add_message(role: :system, content: content, cache_until_here: cache)
218
+ return
219
+ end
179
220
  if cache
180
221
  content = ::RubyLLM::Providers::Anthropic::Content.new(content, cache: true)
181
222
  end
@@ -207,7 +248,7 @@ module TurnKit
207
248
  Class.new(::RubyLLM::Tool) do
208
249
  define_singleton_method(:name) { tool.tool_name }
209
250
  description tool.description
210
- params tool.input_schema
251
+ respond_to?(:params) ? params(tool.input_schema) : parameters(tool.input_schema)
211
252
 
212
253
  define_method(:execute) do |**arguments|
213
254
  raise ToolError, "tools must be executed by TurnKit turns, not the RubyLLM adapter"
@@ -216,6 +257,10 @@ module TurnKit
216
257
  end
217
258
 
218
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
219
264
  tool_calls = Array(response.respond_to?(:tool_calls) ? response.tool_calls&.values : []).map do |call|
220
265
  ToolCall.new(id: call.id, name: call.name, arguments: call.arguments)
221
266
  end
@@ -233,7 +278,7 @@ module TurnKit
233
278
  output_data: response_data(response),
234
279
  tool_calls: tool_calls,
235
280
  usage: usage,
236
- model: response.respond_to?(:model_id) ? response.model_id : model
281
+ model: response_model(response, model)
237
282
  )
238
283
  end
239
284
 
@@ -248,6 +293,14 @@ module TurnKit
248
293
  text = content.to_s
249
294
  text.empty? ? [] : [ { "type" => "text", "text" => text } ]
250
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
251
304
  parts + Array(tool_calls).map { |call| { "type" => "tool_call", "id" => call.id, "name" => call.name, "arguments" => call.arguments } }
252
305
  end
253
306
 
@@ -281,7 +334,25 @@ module TurnKit
281
334
  end
282
335
 
283
336
  def token_value(response, method)
284
- 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
285
356
  end
286
357
 
287
358
  def thinking_token_value(response)
@@ -305,7 +376,7 @@ module TurnKit
305
376
  data: image.respond_to?(:data) ? image.data : nil,
306
377
  mime_type: image.respond_to?(:mime_type) ? image.mime_type : nil,
307
378
  revised_prompt: image.respond_to?(:revised_prompt) ? image.revised_prompt : nil,
308
- model: image.respond_to?(:model_id) ? image.model_id : model,
379
+ model: response_model(image, model),
309
380
  provider: provider&.to_s,
310
381
  usage: usage,
311
382
  params: params,
@@ -327,7 +398,7 @@ module TurnKit
327
398
  part = MediaAnalysisResult.new(
328
399
  text: response_text(response),
329
400
  data: response_data(response),
330
- model: response.respond_to?(:model_id) ? response.model_id : model,
401
+ model: response_model(response, model),
331
402
  provider: provider&.to_s,
332
403
  usage: usage,
333
404
  params: params,
@@ -339,6 +410,7 @@ module TurnKit
339
410
  end
340
411
 
341
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)
342
414
  usage = image.respond_to?(:usage) ? image.usage || {} : {}
343
415
  (usage[key] || usage[key.to_sym]).to_i
344
416
  end
@@ -41,11 +41,11 @@ module TurnKit
41
41
  store.load_conversation(callback) if callback
42
42
  store.atomic(root_conversation(store, store.load_turn(turn.id))) do
43
43
  record = store.load_turn(turn.id)
44
- raise Error, "only pending turns can be submitted" unless record["status"] == "pending"
44
+ raise Error, "only pending or paused turns can be submitted" unless %w[pending paused].include?(record["status"])
45
45
  options = record.fetch("options")
46
46
  options = options.merge("callback_conversation_id" => callback) if callback
47
47
  store.update_turn(turn.id, submitted_at: record["submitted_at"] || Clock.now, options: options,
48
- status: ready?(store, turn.id) ? "pending" : "waiting")
48
+ status: record["status"] == "paused" ? "paused" : ready?(store, turn.id) ? "pending" : "waiting")
49
49
  end
50
50
  enqueue(turn.id)
51
51
  turn.reload
@@ -90,7 +90,7 @@ module TurnKit
90
90
  next if !state["phase"] && !ready?(store, current.fetch("id")) && !deadline_exceeded?(store, current)
91
91
  if current["submitted_at"]
92
92
  others = store.list_turns(conversation_id: current.fetch("conversation_id")).reject { |row| row["id"] == current["id"] }
93
- next if others.any? { |row| %w[running waiting].include?(row["status"]) }
93
+ next if others.any? { |row| %w[running waiting paused].include?(row["status"]) }
94
94
  first = ([current] + others.select { |row| row["submitted_at"] && row["status"] == "pending" }).min_by { |row| [row["created_at"], row["id"]] }
95
95
  next unless first["id"] == current["id"]
96
96
  end
@@ -150,7 +150,7 @@ module TurnKit
150
150
  message = store.append_message(
151
151
  "conversation_id" => destination, "role" => "user", "kind" => "text",
152
152
  "text" => delivery.fetch("payload").fetch("text"),
153
- "metadata" => { "delivery_id" => delivery.fetch("id"), "source_conversation_id" => delivery["source_conversation_id"], "source_turn_id" => delivery["source_turn_id"] }
153
+ "metadata" => { "delivery_id" => delivery.fetch("id"), "principal" => delivery.dig("payload", "principal"), "source_conversation_id" => delivery["source_conversation_id"], "source_turn_id" => delivery["source_turn_id"] }
154
154
  )
155
155
  store.update_delivery(delivery.fetch("id"), message_id: message.fetch("id"), delivered_at: Clock.now)
156
156
  wake(destination, store: store)
@@ -242,7 +242,7 @@ module TurnKit
242
242
  end
243
243
  turns.group_by { |record| record.fetch("conversation_id") }.each do |conversation_id, records|
244
244
  next if store.busy_conversation?(conversation_id, include_pending: false)
245
- record = records.find { |row| row["status"] == "pending" }
245
+ record = records.map { |row| store.load_turn(row.fetch("id")) }.find { |row| row["status"] == "pending" }
246
246
  if record
247
247
  rotated = store.claim_turn(record.fetch("id"), from: "pending", to: "pending")
248
248
  enqueue(record.fetch("id")) if rotated
@@ -4,7 +4,9 @@ module TurnKit
4
4
  # The adapter contract. TurnKit calls clients with the full keyword
5
5
  # signatures below. Custom adapters should subclass TurnKit::Client (or
6
6
  # accept the same keywords) and must not execute tools themselves; TurnKit
7
- # runs tools and persists their results.
7
+ # runs tools and persists their results. Messages may include :provider_parts
8
+ # for opaque continuation state from Result parts with type "provider".
9
+ # Adapters should consume only their own provider kind, never display it.
8
10
  class Client
9
11
  def validate!(model:)
10
12
  true
@@ -20,6 +20,37 @@ module TurnKit
20
20
  append_message(role: "user", kind: "text", text: text, metadata: metadata)
21
21
  end
22
22
 
23
+ # Destination-oriented, durable next-turn input. The destination is also
24
+ # the delivery's source; the application need not create a sender agent.
25
+ def post(text, key:, principal: nil)
26
+ Authorization.authorize!(:send_message, principal: principal, source_conversation: id, destination_conversation: id)
27
+ delivery = store.create_delivery("source_conversation_id" => id, "destination_conversation_id" => id,
28
+ "key" => key, "payload" => { "text" => text, "principal" => principal })
29
+ Background.enqueue
30
+ delivery
31
+ end
32
+
33
+ def messages_after(sequence, principal: nil)
34
+ Authorization.authorize!(:read_messages, principal: principal, destination_conversation: id)
35
+ messages.select { |message| message.sequence > sequence }.map do |message|
36
+ # Provider thinking/signature parts are not application progress.
37
+ attrs = message.to_h
38
+ attrs["content"] = Array(attrs["content"]).reject { |part| %w[thinking provider].include?(part["type"]) }
39
+ Message.new(attrs)
40
+ end
41
+ end
42
+
43
+ def input_status(delivery_id, principal: nil)
44
+ Authorization.authorize!(:read_control, principal: principal, destination_conversation: id)
45
+ delivery = store.load_delivery(delivery_id)
46
+ raise ArgumentError, "delivery belongs to another conversation" unless delivery["destination_conversation_id"] == id
47
+ applied = store.list_turns(conversation_id: id).filter_map do |row|
48
+ request = row.dig("options", "state", "delivery_requests", delivery_id)
49
+ { "turn_id" => row.fetch("id"), "request_id" => request } if request
50
+ end.first
51
+ delivery.merge("application" => applied, "status" => applied ? "applied" : "pending")
52
+ end
53
+
23
54
  def subject_prompt
24
55
  subject.respond_to?(:to_prompt) ? subject.to_prompt.to_s : metadata["turnkit_subject_prompt"].to_s
25
56
  end
@@ -30,6 +30,11 @@ module TurnKit
30
30
  TurnKit.resolve_agent(agent.name)
31
31
  child = nil
32
32
  parent.store.atomic do
33
+ control = parent.control_boundary!
34
+ if control
35
+ child = control
36
+ next
37
+ end
33
38
  existing = parent.store.list_turns(root_turn_id: parent.root_turn_id).find { |row| row["parent_tool_execution_id"] == context.execution.id }
34
39
  if existing
35
40
  child = existing
@@ -40,6 +45,7 @@ module TurnKit
40
45
  child = parent.store.update_turn(built.id, submitted_at: Clock.now, options: options)
41
46
  end
42
47
  end
48
+ return child if child.is_a?(Symbol)
43
49
  Background.enqueue(child.fetch("id"))
44
50
  SubAgentTool.result(child)
45
51
  end
data/lib/turnkit/cost.rb CHANGED
@@ -73,13 +73,13 @@ module TurnKit
73
73
  return new unless defined?(::RubyLLM) && model
74
74
 
75
75
  model_info = ::RubyLLM.models.find(model)
76
- tokens = ::RubyLLM::Tokens.new(
77
- input: usage.input_tokens,
78
- output: usage.output_tokens,
79
- cached: usage.cached_tokens,
80
- cache_creation: usage.cache_write_tokens,
81
- thinking: usage.thinking_tokens
82
- )
76
+ tokens = if ::RubyLLM::Chat.method_defined?(:generate)
77
+ ::RubyLLM::Tokens.new(input: usage.input_tokens, output: usage.output_tokens + usage.thinking_tokens,
78
+ cache_read: usage.cached_tokens, cache_write: usage.cache_write_tokens, thinking: usage.thinking_tokens)
79
+ else
80
+ ::RubyLLM::Tokens.new(input: usage.input_tokens, output: usage.output_tokens,
81
+ cached: usage.cached_tokens, cache_creation: usage.cache_write_tokens, thinking: usage.thinking_tokens)
82
+ end
83
83
  from_hash(::RubyLLM::Cost.new(tokens: tokens, model: model_info).to_h)
84
84
  rescue ::RubyLLM::ModelNotFoundError
85
85
  new
@@ -58,11 +58,13 @@ module TurnKit
58
58
  end
59
59
 
60
60
  def append_message(attributes)
61
- attrs = stringify(attributes)
62
- attrs["sequence"] ||= next_message_sequence(attrs.fetch("conversation_id"))
63
- message = Record.message(attrs)
64
- @mutex.synchronize { @messages[message.fetch("id")] = message }
65
- duplicate(message)
61
+ @mutex.synchronize do
62
+ attrs = stringify(attributes)
63
+ attrs["sequence"] ||= next_message_sequence(attrs.fetch("conversation_id"))
64
+ message = Record.message(attrs)
65
+ @messages[message.fetch("id")] = message
66
+ duplicate(message)
67
+ end
66
68
  end
67
69
 
68
70
  def list_messages(conversation_id, through_sequence: nil, turn_id: nil)
@@ -33,7 +33,10 @@ module TurnKit
33
33
  { role: :assistant, content: [ CONTEXT_SUMMARY_PREFIX, message.text ].reject(&:empty?).join("\n\n") }
34
34
  ]
35
35
  else
36
- [ to_h ]
36
+ projected = to_h
37
+ provider_parts = message.content.select { |part| part["type"] == "provider" }
38
+ projected[:provider_parts] = provider_parts if provider_parts.any?
39
+ [ projected ]
37
40
  end
38
41
  end
39
42
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module TurnKit
4
4
  module Record
5
- TURN_STATUSES = %w[pending waiting running completed failed cancelled stale].freeze
5
+ TURN_STATUSES = %w[pending waiting paused running completed failed cancelled stale].freeze
6
6
  TOOL_EXECUTION_STATUSES = %w[pending running completed failed cancelled interrupted].freeze
7
7
 
8
8
  TURN_UPDATE_KEYS = %w[status options usage cost error output_text output_data submitted_at claim_token started_at heartbeat_at completed_at].freeze
data/lib/turnkit/run.rb CHANGED
@@ -52,6 +52,19 @@ module TurnKit
52
52
  self
53
53
  end
54
54
 
55
+ def pause!(**options)
56
+ turn.pause!(**options)
57
+ self
58
+ end
59
+
60
+ def resume!(**options)
61
+ turn.resume!(**options)
62
+ self
63
+ end
64
+
65
+ def steer!(text, **options) = turn.steer!(text, **options)
66
+ def control_state(**options) = turn.control_state(**options)
67
+
55
68
  def reload
56
69
  turn.reload
57
70
  self
data/lib/turnkit/store.rb CHANGED
@@ -27,7 +27,7 @@ module TurnKit
27
27
 
28
28
  # Stores may optimize these continuation queries without loading history.
29
29
  def busy_conversation?(id, include_pending: true)
30
- list_turns(conversation_id: id).any? { |row| %w[running waiting].include?(row["status"]) || (include_pending && row["submitted_at"] && row["status"] == "pending") }
30
+ list_turns(conversation_id: id).any? { |row| %w[running waiting paused].include?(row["status"]) || (include_pending && row["submitted_at"] && row["status"] == "pending") }
31
31
  end
32
32
 
33
33
  def next_delivery_trigger(id)
@@ -9,10 +9,13 @@ module TurnKit
9
9
  def dispatch(tool_calls)
10
10
  waiting = false
11
11
  tool_calls.each_with_index do |tool_call, index|
12
+ control = turn.control_boundary!
13
+ return control if control
12
14
  # Fan out a contiguous group of subagents, but never reorder ordinary
13
15
  # tools across it or execute past a terminal tool.
14
16
  return :waiting if waiting && !subagent?(tool_for(tool_call.name))
15
17
  execution = run(tool_call, defer_result: waiting)
18
+ return execution if %i[paused steered].include?(execution)
16
19
  if execution == :waiting
17
20
  waiting = true
18
21
  next
@@ -77,11 +80,13 @@ module TurnKit
77
80
  Authorization.authorize!(:tool, principal: context.principal, turn: turn, tool: tool, arguments: tool_call.arguments)
78
81
  # Observe cancellation/reconciliation immediately before crossing the
79
82
  # external-effect boundary. Calls already sent cannot be recalled.
80
- turn.store.atomic { true }
83
+ control = turn.control_boundary!
84
+ return control if control
81
85
  if turn.background? && subagent?(tool)
82
86
  return delegate(tool, tool_call, context)
83
87
  end
84
88
  value = call_tool(tool, tool_call.arguments, context: context)
89
+ return value if tool == LaunchAgentTool && %i[paused steered waiting].include?(value)
85
90
  return :waiting if value == :waiting && tool == WaitTool
86
91
  normalize_payload(value)
87
92
  rescue LostClaim
@@ -178,6 +183,8 @@ module TurnKit
178
183
  TurnKit.resolve_agent(tool.agent.name)
179
184
  child = turn.store.atomic_graph do
180
185
  turn.store.atomic(Background.root_conversation(turn.store, turn.store.load_turn(turn.id))) do
186
+ control = turn.control_boundary!
187
+ next control if control
181
188
  row = turn.store.list_turns(root_turn_id: turn.root_turn_id).find { |candidate| candidate["parent_tool_execution_id"] == context.execution.id }
182
189
  unless row
183
190
  built = tool.build_child(task: arguments.fetch("task"), context: context)
@@ -187,6 +194,7 @@ module TurnKit
187
194
  row
188
195
  end
189
196
  end
197
+ return child if child.is_a?(Symbol)
190
198
  unless Background::TERMINAL.include?(child["status"])
191
199
  Background.enqueue(child.fetch("id")) if child["status"] == "pending"
192
200
  return :waiting
data/lib/turnkit/turn.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  module TurnKit
4
4
  class Turn
5
+ include TurnControls
5
6
  STATUSES = Record::TURN_STATUSES
6
7
 
7
8
  attr_reader :agent, :conversation, :store, :budget, :depth
@@ -77,7 +78,10 @@ module TurnKit
77
78
  end
78
79
 
79
80
  def suspend!
80
- update!(status: "waiting", claim_token: nil)
81
+ store.atomic do
82
+ reload
83
+ update!(status: @record.dig("options", "controls", "pause_requested") ? "paused" : "waiting", claim_token: nil)
84
+ end
81
85
  end
82
86
 
83
87
  # Revokes the local claim. An already-sent remote request cannot be
@@ -292,6 +296,12 @@ module TurnKit
292
296
 
293
297
  def execute
294
298
  loop do
299
+ control = control_boundary!
300
+ break if control == :paused
301
+ if control == :waiting
302
+ suspend!
303
+ break
304
+ end
295
305
  @budget = execution_budget
296
306
  budget.check!(depth: depth)
297
307
  state = @record.dig("options", "state") || {}
@@ -301,6 +311,9 @@ module TurnKit
301
311
  TurnKit::Compaction.maybe_compact!(self)
302
312
  request = model_request
303
313
  emit_model_requested("model.requested", request)
314
+ control = control_boundary!
315
+ break if control == :paused
316
+ next if control
304
317
  result = call_client(request)
305
318
  cost = Cost.from_usage(result.usage, model: result.model || model)
306
319
  store.atomic do
@@ -314,6 +327,8 @@ module TurnKit
314
327
  when "tools"
315
328
  runner = ToolRunner.new(self)
316
329
  terminal = runner.dispatch(Result.new(parts: state.fetch("parts")).tool_calls)
330
+ break if terminal == :paused
331
+ next if terminal == :steered
317
332
  if terminal == :waiting
318
333
  suspend!
319
334
  break
@@ -338,7 +353,7 @@ module TurnKit
338
353
  emit("output_policy.revision", violation_count: audit.violations.length, attempt: revisions_used + 1)
339
354
  else
340
355
  complete_with_output(candidate, output_data: state["output_data"], audit: audit)
341
- break
356
+ break unless status == "running"
342
357
  end
343
358
  end
344
359
  end
@@ -383,7 +398,7 @@ module TurnKit
383
398
  dynamic_instructions: dynamic_instructions,
384
399
  thinking: thinking,
385
400
  output_schema: output_schema,
386
- metadata: { turn_id: id, conversation_id: conversation.id },
401
+ metadata: { turn_id: id, conversation_id: conversation.id, request_id: @record.dig("options", "state", "request_id") },
387
402
  report: prompt.report
388
403
  )
389
404
  end
@@ -412,7 +427,22 @@ module TurnKit
412
427
  end
413
428
 
414
429
  def llm_messages
415
- MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
430
+ messages = TurnKit::Compaction.project(conversation.messages_for_turn(self))
431
+ # Delivery time is not application time. A next-turn message can arrive
432
+ # between an earlier turn's tool call/result or before its steering.
433
+ # Keep UI sequence order intact, but place each delivery at the frozen
434
+ # context boundary of its first receiving turn in the provider input.
435
+ turns = store.list_turns(conversation_id: conversation.id)
436
+ messages = messages.sort_by do |message|
437
+ delivery_id = message.metadata["delivery_id"]
438
+ receiver = if delivery_id
439
+ turns.find { |row| row.dig("options", "state", "delivery_requests", delivery_id) } ||
440
+ turns.find { |row| row["context_message_sequence"] >= message.sequence &&
441
+ (row["submitted_at"] || row["started_at"] || row["id"] == id) }
442
+ end
443
+ receiver ? [receiver.fetch("context_message_sequence"), 1, message.sequence] : [message.sequence, 0, 0]
444
+ end
445
+ MessageProjection.for(messages)
416
446
  end
417
447
 
418
448
  def emit_model_requested(type, request)
@@ -475,7 +505,7 @@ module TurnKit
475
505
  message = conversation.append_message(role: "assistant", kind: "media_analysis", content: result.media_analyses.map { |analysis| analysis.to_h.merge("type" => "media_analysis") }, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
476
506
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
477
507
  else
478
- message = conversation.append_message(role: "assistant", kind: "text", text: result.text, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
508
+ message = conversation.append_message(role: "assistant", kind: "text", content: result.parts, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
479
509
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
480
510
  end
481
511
  end
@@ -505,10 +535,14 @@ module TurnKit
505
535
  else
506
536
  attrs[:status] = "completed"
507
537
  end
508
- store.atomic(Background.root_conversation(store, @record)) do
538
+ controlled = store.atomic(Background.root_conversation(store, @record)) do
539
+ control = control_boundary!
540
+ next control if control
509
541
  update_state!("policy_audit" => audit.to_h) if audit
510
542
  update!(attrs)
543
+ nil
511
544
  end
545
+ return if controlled
512
546
  emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length) if audit
513
547
 
514
548
  if failed?
@@ -576,7 +610,19 @@ module TurnKit
576
610
  store.atomic do
577
611
  @budget = execution_budget
578
612
  budget.count_iteration!
579
- update_state!("iterations" => Turn.iterations_for(@record) + 1)
613
+ request_id = SecureRandom.uuid
614
+ options = store.load_turn(id).fetch("options")
615
+ controls = options["controls"] || {}
616
+ inputs = controls.fetch("inputs", []).map do |input|
617
+ input["message_id"] && !input["request_id"] ? input.merge("request_id" => request_id) : input
618
+ end
619
+ update!(options: options.merge("controls" => controls.merge("inputs" => inputs)))
620
+ deliveries = options.dig("state", "delivery_requests") || {}
621
+ conversation.messages_for_turn(self).each do |message|
622
+ delivery_id = message.metadata["delivery_id"]
623
+ deliveries[delivery_id] ||= request_id if delivery_id
624
+ end
625
+ update_state!("iterations" => Turn.iterations_for(@record) + 1, "request_id" => request_id, "delivery_requests" => deliveries)
580
626
  end
581
627
  end
582
628
 
@@ -584,7 +630,7 @@ module TurnKit
584
630
  # write-once turn configuration. Reads fall back to the legacy top-level
585
631
  # keys for turns persisted before the split.
586
632
  def update_state!(changes)
587
- options = @record["options"] || {}
633
+ options = store.load_turn(id)["options"] || {}
588
634
  update!(options: options.merge("state" => (options["state"] || {}).merge(changes)))
589
635
  end
590
636
 
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ # Human controls share the execution root lock. Options are the durable
5
+ # source of truth; no callbacks or job payloads are needed for recovery.
6
+ module TurnControls
7
+ def pause!(descendants: :retain, principal: nil)
8
+ control_tree(:pause, descendants, principal) do |row|
9
+ controls = row.dig("options", "controls") || {}
10
+ attrs = { options: row.fetch("options").merge("controls" => controls.merge("pause_requested" => true)) }
11
+ attrs[:status] = "paused" unless row["status"] == "running"
12
+ @base_store.update_turn(row.fetch("id"), attrs)
13
+ end
14
+ reload
15
+ end
16
+
17
+ def resume!(descendants: :retain, principal: nil)
18
+ control_tree(:resume, descendants, principal) do |row|
19
+ controls = row.dig("options", "controls") || {}
20
+ attrs = { options: row.fetch("options").merge("controls" => controls.merge("pause_requested" => false)) }
21
+ if row["status"] == "paused"
22
+ attrs[:status] = Background.ready?(@base_store, row.fetch("id")) ? "pending" : "waiting"
23
+ end
24
+ @base_store.update_turn(row.fetch("id"), attrs)
25
+ end
26
+ Background.enqueue if background?
27
+ reload
28
+ end
29
+
30
+ def steer!(text, key:, principal: nil, descendants: :retain)
31
+ raise ArgumentError, "key must be a nonempty string" unless key.is_a?(String) && !key.empty?
32
+ text = text.to_s
33
+ principal = JSON.parse(JSON.generate(principal))
34
+ receipts = []
35
+ control_tree(:steer, descendants, principal) do |row|
36
+ controls = row.dig("options", "controls") || {}
37
+ inputs = controls.fetch("inputs", [])
38
+ existing = inputs.find { |input| input["key"] == key }
39
+ if existing
40
+ unless existing["text"] == text && existing["principal"] == principal
41
+ raise ToolError, "steering key is already used for a different input"
42
+ end
43
+ receipts << existing
44
+ next
45
+ end
46
+ if Background::TERMINAL.include?(row["status"]) || row["status"] == "stale"
47
+ raise Error, "cannot steer a #{row['status']} turn; post next-turn input instead" if row["id"] == id
48
+ next
49
+ end
50
+ input = { "id" => SecureRandom.uuid, "key" => key, "text" => text,
51
+ "principal" => principal, "turn_id" => row.fetch("id"), "sequence" => inputs.length + 1 }
52
+ @base_store.update_turn(row.fetch("id"), options: row.fetch("options").merge(
53
+ "controls" => controls.merge("inputs" => inputs + [input])))
54
+ receipts << input
55
+ end
56
+ receipts
57
+ end
58
+
59
+ def control_state(principal: nil)
60
+ Authorization.authorize!(:read_control, principal: principal, turn: self)
61
+ row = @base_store.load_turn(id)
62
+ { "status" => row.fetch("status"), "controls" => row.dig("options", "controls") || {} }
63
+ end
64
+
65
+ # Called before dispatching another unit of work, never during a remote
66
+ # call. The lock acquisition is the dispatch/control linearization point.
67
+ def control_boundary!
68
+ store.atomic do
69
+ reload
70
+ if @record.dig("options", "controls", "pause_requested")
71
+ update!(status: "paused", claim_token: nil)
72
+ next :paused
73
+ end
74
+ inputs = @record.dig("options", "controls", "inputs") || []
75
+ pending = inputs.reject { |input| input["message_id"] }
76
+ next unless pending.any?
77
+ unless Background.ready?(store, id)
78
+ next Background.deadline_exceeded?(store, @record) ? nil : :waiting
79
+ end
80
+
81
+ executions = store.list_tool_executions(turn_id: id)
82
+ parts = @record.dig("options", "state", "parts") || []
83
+ parts.select { |part| part["type"] == "tool_call" }.each do |part|
84
+ execution = executions.find { |row| row["tool_call_id"] == part["id"] }
85
+ child = store.list_turns(root_turn_id: root_turn_id).find { |row| execution && row["parent_tool_execution_id"] == execution["id"] }
86
+ if child && Background::TERMINAL.include?(child["status"]) && %w[pending running].include?(execution["status"])
87
+ store.claim_tool_execution(execution.fetch("id"), from: execution.fetch("status"), to: "completed",
88
+ result: SubAgentTool.result(child), completed_at: Clock.now)
89
+ elsif !execution
90
+ store.create_tool_execution("turn_id" => id, "tool_call_id" => part.fetch("id"), "tool_name" => part.fetch("name"),
91
+ "arguments" => part["arguments"], "status" => "cancelled", "completed_at" => Clock.now,
92
+ "result" => { "skipped" => true, "message" => "not executed: superseded by human steering" })
93
+ end
94
+ end
95
+ executions = Reconciliation.interrupt_tool_executions(@record, store: store)
96
+ # Complete known results and close unexecuted proposals before adding
97
+ # human input: providers require a result for every assistant tool ID.
98
+ Reconciliation.repair_transcript(@record, executions, store: store)
99
+ pending.each do |input|
100
+ message = conversation.append_message(role: "user", kind: "text", text: input.fetch("text"), turn_id: id,
101
+ metadata: { "steering_id" => input.fetch("id"), "principal" => input["principal"] })
102
+ input["message_id"] = message.id
103
+ end
104
+ options = @record.fetch("options")
105
+ update!(options: options.merge("controls" => options.fetch("controls").merge("inputs" => inputs)))
106
+ update_state!("phase" => "model", "parts" => nil, "candidate" => nil, "output_data" => nil, "terminal_tool_name" => nil)
107
+ :steered
108
+ end
109
+ end
110
+
111
+ private
112
+ def control_tree(action, descendants, principal)
113
+ raise ArgumentError, "descendants must be :retain or :cascade" unless %i[retain cascade].include?(descendants)
114
+ @base_store.atomic(Background.root_conversation(@base_store, @record)) do
115
+ rows = [@base_store.load_turn(id)]
116
+ if descendants == :cascade
117
+ all = @base_store.list_turns(root_turn_id: root_turn_id)
118
+ loop do
119
+ ids = rows.map { |row| row.fetch("id") }
120
+ added = all.select { |row| ids.include?(row["parent_turn_id"]) && !ids.include?(row["id"]) }
121
+ break if added.empty?
122
+ rows.concat(added)
123
+ end
124
+ end
125
+ rows.each do |row|
126
+ Authorization.authorize!(action, principal: principal,
127
+ turn: row["id"] == id ? self : Background.load_turn(row.fetch("id"), store: @base_store), descendants: descendants)
128
+ end
129
+ rows.each do |row|
130
+ next if action != :steer && (Background::TERMINAL.include?(row["status"]) || row["status"] == "stale")
131
+ yield row
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/turnkit.rb CHANGED
@@ -45,6 +45,7 @@ require_relative "turnkit/sub_agent_tool"
45
45
  require_relative "turnkit/load_skill_tool"
46
46
  require_relative "turnkit/message_projection"
47
47
  require_relative "turnkit/tool_runner"
48
+ require_relative "turnkit/turn_controls"
48
49
  require_relative "turnkit/turn"
49
50
  require_relative "turnkit/usage"
50
51
  require_relative "turnkit/run"
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.6.0
4
+ version: 0.7.0
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-09-06 00:00:00.000000000 Z
11
+ date: 2026-09-10 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: TurnKit is a Ruby/Rails agent runtime for durable AI conversations, application
14
14
  runs, orchestrator agents, tool calling, skills, sub-agents, context compaction,
@@ -82,6 +82,7 @@ files:
82
82
  - lib/turnkit/tool_execution.rb
83
83
  - lib/turnkit/tool_runner.rb
84
84
  - lib/turnkit/turn.rb
85
+ - lib/turnkit/turn_controls.rb
85
86
  - lib/turnkit/usage.rb
86
87
  - lib/turnkit/version.rb
87
88
  - lib/turnkit/view_media_tool.rb