turnkit 0.6.0 → 0.7.1

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: 170286a1c5a1975fe34234a667c946c465cb6512509be4bf06d397f48b2f9f2e
4
+ data.tar.gz: 8798216dcaece0679c5f8365ee5a2af900b9660694cb3a640c1924f99b814c5d
5
5
  SHA512:
6
- metadata.gz: dbe5569a73d9118776e207567a515642d9fc59923de34b5084499623f36798e9c826832bf35d7f8105b9272252edb6da244faf896f9509e59e571d9db7f9022f
7
- data.tar.gz: 31b2decb3c2336eafd7f943eaff23cf149b88c67067b75c785f9cd71960349a9f89a20da5dfd1d415fb1f7e083f9e061514f359568c4a5b92fc44426a8e05fcd
6
+ metadata.gz: 2e0286f571241210786433490a66cbf54d7b2453e1202d60ada2a45ae7fa6c1c370857cf6ad96348ab1cb1e4d16bdbdf0b8a26ccc59cb7a712718f0e9e309ba8
7
+ data.tar.gz: 85dcbfe855bc6937f495eddbc7a20bd32ba994035b024b8958b08cf9ce69c1ac5f98171722373462c034a8426f658788fd13c8ee49cb9d6b7954369c38a749f5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.1 - 2026-09-10
4
+
5
+ - Preserve OpenAI prompt prefixes with durable, append-only dynamic context
6
+ snapshots instead of recombining changing data into leading instructions.
7
+ Deduplicate unchanged snapshots across retries/resume, preserve opaque
8
+ Responses reasoning and tool pairing, and refresh full context after
9
+ compaction. Other clients retain the separate-instructions contract.
10
+ - Add the `dynamic_context` message kind (no schema migration; upgrade workers
11
+ and custom kind allowlists together). Exclude snapshots from public progress
12
+ reads. Clarify that `prompt_cache: :off` does not disable OpenAI implicit caching.
13
+
14
+ ## 0.7.0 - 2026-09-10
15
+
16
+ - Add destination-oriented `Conversation#post`, durable input/request receipts,
17
+ authorized transcript cursor reads, and cooperative `pause!`, `resume!`, and
18
+ `steer!` controls on turns/runs, including parent-linked subtree controls.
19
+ - Preserve in-flight results while skipping stale tool proposals before steering;
20
+ keep paused conversations closed to automatic wake and retain dependency waits.
21
+ - Enqueue newly eligible joined turns in the same maintenance pass, and serialize
22
+ MemoryStore message insertion with sequence allocation for cursor catchup.
23
+ - Project busy-time deliveries at their receiving turn's context boundary so
24
+ earlier steering cannot override next-turn input or split prior tool exchanges.
25
+ - Support optional RubyLLM 2.0.0.rc2 with `protocol: :responses`, opaque provider
26
+ replay, single-response `generate`, and updated usage/media APIs. Keep RubyLLM
27
+ 1.16 compatibility and the stable development dependency; no separate HTTP
28
+ adapter or tool executor. Add live Astra/xhigh Rails 8.1/Sidekiq validation.
29
+ - Preserve complete native Anthropic/Gemini thinking/tool blocks across durable
30
+ reload, steering and recovery, without exposing opaque state in UI/activity.
31
+ Avoid duplicate raw Gemini calls and double-counted native thinking on 1.16;
32
+ validate Claude Opus 4.8/high and Gemini 3.1 Pro/high on both SDK versions.
33
+ - No migration on the 0.6.0 schema. Add `paused` to application/custom-store status
34
+ handling and upgrade workers together before enabling controls. Existing
35
+ delivery retry payloads remain compatible. See `docs/interactive-research.md`.
36
+
3
37
  ## 0.6.0 - 2026-09-06
4
38
 
5
39
  ### 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**:
@@ -452,6 +463,48 @@ agent = TurnKit::Agent.new(
452
463
  `TurnKit.prompt_behavior`, and `TurnKit.context_contributors` remain available
453
464
  for generated prompts.
454
465
 
466
+ ### Dynamic context and OpenAI prompt caching
467
+
468
+ Generated prompts keep stable instructions separate from subject, live context,
469
+ and environment. With the RubyLLM OpenAI adapter (Responses or Chat Completions),
470
+ TurnKit persists each changed **full context snapshot** as a `dynamic_context`
471
+ conversation message before model dispatch. It replays older snapshots unchanged
472
+ and appends new ones after completed tool exchanges. They render as labeled user
473
+ reference-data messages, not new user requests or top-level instructions;
474
+ the newest snapshot replaces earlier snapshots for current state. Keep policies
475
+ in stable instructions and supply current data through context contributors.
476
+
477
+ Identical snapshots are deduplicated against durable, model-visible history,
478
+ including after retry/resume. Empty context clears a previous snapshot. Changed
479
+ context adds history, so keep contributors concise; compaction can remove old
480
+ snapshots and resets that portion of the prefix. Always supply the full current
481
+ context, not deltas. Changing tools, skills, schemas, model settings, or stable
482
+ instructions can also invalidate a prefix. Existing histories are not rewritten.
483
+
484
+ Custom clients retain the separate `instructions` / `dynamic_instructions`
485
+ contract. Clients opting into `dynamic_context_in_history?(model:)` receive
486
+ snapshots in `messages` and empty `dynamic_instructions`. Other clients do not
487
+ receive these historical snapshot messages. Raw `Conversation#messages` includes
488
+ them; public `messages_after` progress reads exclude them. No schema migration is
489
+ needed, but custom message-kind allowlists must accept `dynamic_context`, and
490
+ workers reading those conversations must be upgraded together.
491
+
492
+ Direct `adapter.chat` callers still receive fresh dynamic context at the tail,
493
+ but must preserve prior snapshots themselves (using
494
+ `TurnKit::MessageProjection.dynamic_context(text)` in their own history) and
495
+ avoid supplying the same snapshot again through `dynamic_instructions`.
496
+ A custom `system_prompt:` string/callable is treated entirely as stable
497
+ instructions; recombining `prompt.dynamic` there bypasses this protection.
498
+
499
+ `TurnKit.prompt_cache = :auto` enables TurnKit's existing Anthropic cache markers;
500
+ `:off` suppresses those markers. It is **not an OpenAI cache-disable switch**.
501
+ OpenAI implicit caching remains provider-default in either setting, including
502
+ when RubyLLM uses `store: false` or a fresh chat object. TurnKit does not force
503
+ explicit breakpoints, cache keys, or TTLs. Matching wire prefixes make reuse
504
+ possible, not guaranteed: eligibility, routing, lifetime, and model-specific
505
+ boundaries still matter. See [OpenAI's prompt-caching guide](https://developers.openai.com/api/docs/guides/prompt-caching)
506
+ and measure actual cache reads/writes before claiming savings.
507
+
455
508
  ### Tools
456
509
 
457
510
  Create a tool:
@@ -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)
@@ -23,17 +30,40 @@ module TurnKit
23
30
  raise ModelAccessError, "#{key_name} is required for #{model}. Set ENV[#{key_name.inspect}] or configure RubyLLM before running TurnKit."
24
31
  end
25
32
 
33
+ def dynamic_context_in_history?(model:)
34
+ ensure_ruby_llm!
35
+ # Resolve the catalog provider without creating a connection or
36
+ # requiring credentials, so prompt previews remain offline/read-only.
37
+ ::RubyLLM.models.find(model).provider.to_s == "openai"
38
+ rescue ConfigError
39
+ raise
40
+ rescue ::RubyLLM::ModelNotFoundError
41
+ # Preserve unknown-model previews; normal dispatch reports SDK errors.
42
+ false
43
+ end
44
+
26
45
  def chat(model:, messages:, tools:, instructions:, dynamic_instructions: nil, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
27
46
  ensure_ruby_llm!
28
47
  configure_from_environment
29
48
 
30
- chat = ::RubyLLM.chat(model: model)
31
- add_instructions(chat, instructions, dynamic_instructions, model: model)
49
+ validate!(model: model) if @protocol
50
+ chat = ::RubyLLM.chat(**{ model: model, protocol: @protocol }.compact)
51
+ chat.with_provider_options(metadata: metadata.transform_values(&:to_s)) if @protocol == :responses && metadata
52
+ context_in_history = chat.model.provider.to_s == "openai"
53
+ add_instructions(chat, instructions, context_in_history ? nil : dynamic_instructions, model: model)
32
54
  chat.with_temperature(temperature) if temperature
33
55
  apply_thinking(chat, thinking)
34
56
  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) }
57
+ Array(tools).each do |tool|
58
+ chat.respond_to?(:with_tool) ? chat.with_tool(ruby_llm_tool(tool)) : chat.with_tools(ruby_llm_tool(tool))
59
+ end
60
+ tool_names = {}
61
+ Array(messages).each { |message| add_message(chat, message, provider: chat.model.provider, tool_names: tool_names) }
62
+ # The runtime supplies durable snapshots in messages. Direct adapter
63
+ # callers must preserve this message themselves on subsequent calls.
64
+ if context_in_history && !dynamic_instructions.to_s.empty?
65
+ add_message(chat, MessageProjection.dynamic_context(dynamic_instructions))
66
+ end
37
67
 
38
68
  response = complete_without_tool_execution(chat)
39
69
  normalize_response(response, model: model)
@@ -56,7 +86,7 @@ module TurnKit
56
86
  size: size || "1024x1024",
57
87
  with: input_images,
58
88
  mask: mask,
59
- params: params || {}
89
+ **{ (::RubyLLM::Chat.method_defined?(:generate) ? :provider_options : :params) => params || {} }
60
90
  )
61
91
  normalize_image_response(image, model: model, provider: provider, params: { "size" => size || "1024x1024" }.merge(params || {}), metadata: metadata)
62
92
  rescue ConfigError
@@ -69,13 +99,24 @@ module TurnKit
69
99
  ensure_ruby_llm!
70
100
  configure_from_environment
71
101
  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
102
 
75
103
  chat = ::RubyLLM.chat(model: model)
76
104
  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)
105
+ if params && !params.empty?
106
+ if ::RubyLLM::Chat.method_defined?(:generate)
107
+ chat.with_provider_options(**params)
108
+ else
109
+ chat.with_params(**params)
110
+ end
111
+ end
112
+ if ::RubyLLM::Chat.method_defined?(:generate)
113
+ attachment = ::RubyLLM::Attachment.new(media_input.attachment_source, filename: media_input.filename)
114
+ chat.add_message(role: :user, content: objective.to_s, attachments: [attachment])
115
+ else
116
+ content = ::RubyLLM::Content.new(objective.to_s)
117
+ content.add_attachment(media_input.attachment_source, filename: media_input.filename)
118
+ chat.add_message(role: :user, content: content)
119
+ end
79
120
 
80
121
  response = complete_without_tool_execution(chat)
81
122
  normalize_media_analysis_response(response, media: media_input, model: model, provider: provider, params: params || {}, metadata: metadata)
@@ -133,12 +174,10 @@ module TurnKit
133
174
  end
134
175
  end
135
176
 
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.
177
+ # 2.0's public generate requests one completion without executing tools.
178
+ # 1.16 needs its private provider_completion, guarded by a canary test.
141
179
  def complete_without_tool_execution(chat)
180
+ return chat.generate if chat.respond_to?(:generate)
142
181
  unless chat.respond_to?(:provider_completion, true)
143
182
  raise ConfigError, "TurnKit::Adapters::RubyLLM requires ruby_llm >= 1.16 (RubyLLM::Chat#provider_completion not found)"
144
183
  end
@@ -146,15 +185,31 @@ module TurnKit
146
185
  chat.send(:provider_completion)
147
186
  end
148
187
 
149
- def add_message(chat, message)
188
+ def add_message(chat, message, provider: nil, tool_names: {})
150
189
  role = (message[:role] || message["role"]).to_sym
151
190
  content = message[:content] || message["content"] || ""
191
+ kind = { "openai" => "openai_responses", "anthropic" => "anthropic", "gemini" => "gemini" }[provider.to_s]
192
+ replay = Array(message[:provider_parts] || message["provider_parts"]).find { |part| part["kind"] == kind } if kind
193
+ calls = ruby_llm_tool_calls(message[:tool_calls] || message["tool_calls"])
194
+ raw_content = replay&.fetch("data")
195
+ if raw_content && %w[anthropic gemini].include?(kind) && !::RubyLLM::Chat.method_defined?(:generate)
196
+ content = ::RubyLLM::Content::Raw.new(raw_content)
197
+ raw_content = nil
198
+ if kind == "gemini"
199
+ # 1.16 appends normalized calls after Raw content. Omit those
200
+ # duplicates and supply names for its positional function results.
201
+ calls&.each { |id, call| tool_names[id] = call.name }
202
+ calls = nil
203
+ end
204
+ end
205
+ call_id = message[:tool_call_id] || message["tool_call_id"]
152
206
  chat.add_message(
153
207
  {
154
208
  role: role,
155
209
  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"]
210
+ raw_content: raw_content,
211
+ tool_calls: calls,
212
+ tool_call_id: tool_names.fetch(call_id, call_id)
158
213
  }.compact
159
214
  )
160
215
  end
@@ -176,6 +231,10 @@ module TurnKit
176
231
  content = content.to_s.strip
177
232
  return if content.empty?
178
233
 
234
+ if ::RubyLLM::Chat.method_defined?(:generate)
235
+ chat.add_message(role: :system, content: content, cache_until_here: cache)
236
+ return
237
+ end
179
238
  if cache
180
239
  content = ::RubyLLM::Providers::Anthropic::Content.new(content, cache: true)
181
240
  end
@@ -207,7 +266,7 @@ module TurnKit
207
266
  Class.new(::RubyLLM::Tool) do
208
267
  define_singleton_method(:name) { tool.tool_name }
209
268
  description tool.description
210
- params tool.input_schema
269
+ respond_to?(:params) ? params(tool.input_schema) : parameters(tool.input_schema)
211
270
 
212
271
  define_method(:execute) do |**arguments|
213
272
  raise ToolError, "tools must be executed by TurnKit turns, not the RubyLLM adapter"
@@ -216,6 +275,10 @@ module TurnKit
216
275
  end
217
276
 
218
277
  def normalize_response(response, model:)
278
+ raw = response.raw.body if response.respond_to?(:raw) && response.raw.respond_to?(:body)
279
+ if raw.is_a?(Hash) && raw["object"] == "response" && raw["status"] != "completed"
280
+ raise ModelError, "OpenAI Responses #{raw['status']}: #{raw.dig('incomplete_details', 'reason')}"
281
+ end
219
282
  tool_calls = Array(response.respond_to?(:tool_calls) ? response.tool_calls&.values : []).map do |call|
220
283
  ToolCall.new(id: call.id, name: call.name, arguments: call.arguments)
221
284
  end
@@ -233,7 +296,7 @@ module TurnKit
233
296
  output_data: response_data(response),
234
297
  tool_calls: tool_calls,
235
298
  usage: usage,
236
- model: response.respond_to?(:model_id) ? response.model_id : model
299
+ model: response_model(response, model)
237
300
  )
238
301
  end
239
302
 
@@ -248,6 +311,14 @@ module TurnKit
248
311
  text = content.to_s
249
312
  text.empty? ? [] : [ { "type" => "text", "text" => text } ]
250
313
  end.compact
314
+ raw = response.raw.body if response.respond_to?(:raw) && response.raw.respond_to?(:body)
315
+ if raw.is_a?(Hash) && raw["object"] == "response"
316
+ parts << { "type" => "provider", "kind" => "openai_responses", "data" => raw.fetch("output") }
317
+ elsif raw.is_a?(Hash) && raw["type"] == "message" && raw["role"] == "assistant"
318
+ parts << { "type" => "provider", "kind" => "anthropic", "data" => raw.fetch("content") }
319
+ elsif raw.is_a?(Hash) && raw.dig("candidates", 0, "content", "parts")
320
+ parts << { "type" => "provider", "kind" => "gemini", "data" => raw.dig("candidates", 0, "content", "parts") }
321
+ end
251
322
  parts + Array(tool_calls).map { |call| { "type" => "tool_call", "id" => call.id, "name" => call.name, "arguments" => call.arguments } }
252
323
  end
253
324
 
@@ -281,7 +352,25 @@ module TurnKit
281
352
  end
282
353
 
283
354
  def token_value(response, method)
284
- response.respond_to?(method) ? response.public_send(method).to_i : 0
355
+ if response.respond_to?(method)
356
+ value = response.public_send(method).to_i
357
+ raw = response.raw.body if response.respond_to?(:raw) && response.raw.respond_to?(:body)
358
+ native = raw.is_a?(Hash) && (raw["candidates"] || raw["type"] == "message")
359
+ # Native 1.16 providers also include thinking in their output count.
360
+ return native && method == :output_tokens ? value - thinking_token_value(response) : value
361
+ end
362
+ return 0 unless response.respond_to?(:tokens)
363
+
364
+ key = { input_tokens: :input, output_tokens: :output, cached_tokens: :cache_read,
365
+ cache_creation_tokens: :cache_write, thinking_tokens: :thinking, reasoning_tokens: :thinking }.fetch(method)
366
+ value = response.tokens.public_send(key).to_i
367
+ # RubyLLM 2.0 includes thinking in output; TurnKit buckets are additive.
368
+ method == :output_tokens ? value - response.tokens.thinking.to_i : value
369
+ end
370
+
371
+ def response_model(response, fallback)
372
+ return response.model if response.respond_to?(:model)
373
+ response.respond_to?(:model_id) ? response.model_id : fallback
285
374
  end
286
375
 
287
376
  def thinking_token_value(response)
@@ -305,7 +394,7 @@ module TurnKit
305
394
  data: image.respond_to?(:data) ? image.data : nil,
306
395
  mime_type: image.respond_to?(:mime_type) ? image.mime_type : nil,
307
396
  revised_prompt: image.respond_to?(:revised_prompt) ? image.revised_prompt : nil,
308
- model: image.respond_to?(:model_id) ? image.model_id : model,
397
+ model: response_model(image, model),
309
398
  provider: provider&.to_s,
310
399
  usage: usage,
311
400
  params: params,
@@ -327,7 +416,7 @@ module TurnKit
327
416
  part = MediaAnalysisResult.new(
328
417
  text: response_text(response),
329
418
  data: response_data(response),
330
- model: response.respond_to?(:model_id) ? response.model_id : model,
419
+ model: response_model(response, model),
331
420
  provider: provider&.to_s,
332
421
  usage: usage,
333
422
  params: params,
@@ -339,6 +428,7 @@ module TurnKit
339
428
  end
340
429
 
341
430
  def image_usage_value(image, key)
431
+ return image.tokens.public_send(key == "input_tokens" ? :input : :output).to_i if image.respond_to?(:tokens)
342
432
  usage = image.respond_to?(:usage) ? image.usage || {} : {}
343
433
  (usage[key] || usage[key.to_sym]).to_i
344
434
  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,12 +4,21 @@ 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
11
13
  end
12
14
 
15
+ # Opt in to durable, append-only dynamic context messages. The runtime
16
+ # then passes empty dynamic_instructions; ordinary clients keep the
17
+ # existing separate-instructions contract and do not see these messages.
18
+ def dynamic_context_in_history?(model:)
19
+ false
20
+ end
21
+
13
22
  def chat(model:, messages:, tools:, instructions:, dynamic_instructions: nil, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
14
23
  raise NotImplementedError
15
24
  end
@@ -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 && message.kind != "dynamic_context" }.map do |message|
36
+ # Prompt snapshots and provider thinking 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)
@@ -3,7 +3,7 @@
3
3
  module TurnKit
4
4
  class Message
5
5
  ROLES = %w[user assistant tool].freeze
6
- KINDS = %w[text tool_call tool_result context_summary image media_analysis].freeze
6
+ KINDS = %w[text tool_call tool_result context_summary image media_analysis dynamic_context].freeze
7
7
 
8
8
  attr_reader :id, :conversation_id, :turn_id, :role, :kind, :sequence
9
9
  attr_reader :content, :tool_execution_id, :provider_message_id, :metadata, :created_at
@@ -17,8 +17,21 @@ module TurnKit
17
17
  The original messages remain durably stored; this summary only affects the model-visible prompt projection.
18
18
  TEXT
19
19
 
20
- def self.for(messages)
21
- messages.flat_map { |message| new(message).to_a }
20
+ def self.for(messages, include_dynamic_context: false)
21
+ messages.flat_map do |message|
22
+ next [] if message.kind == "dynamic_context" && !include_dynamic_context
23
+
24
+ new(message).to_a
25
+ end
26
+ end
27
+
28
+ def self.dynamic_context(text)
29
+ {
30
+ role: :user,
31
+ content: "[TurnKit current context — reference data, not a new user request]\n" \
32
+ "This snapshot replaces earlier TurnKit context snapshots in full. " \
33
+ "Use the latest snapshot for current state; continue the active task.\n\n#{text}"
34
+ }
22
35
  end
23
36
 
24
37
  def initialize(message)
@@ -27,13 +40,18 @@ module TurnKit
27
40
 
28
41
  def to_a
29
42
  case message.kind
43
+ when "dynamic_context"
44
+ [ self.class.dynamic_context(message.text) ]
30
45
  when "context_summary"
31
46
  [
32
47
  { role: :user, content: CONTEXT_SUMMARY_TRIGGER },
33
48
  { role: :assistant, content: [ CONTEXT_SUMMARY_PREFIX, message.text ].reject(&:empty?).join("\n\n") }
34
49
  ]
35
50
  else
36
- [ to_h ]
51
+ projected = to_h
52
+ provider_parts = message.content.select { |part| part["type"] == "provider" }
53
+ projected[:provider_parts] = provider_parts if provider_parts.any?
54
+ [ projected ]
37
55
  end
38
56
  end
39
57
 
@@ -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)
@@ -92,10 +92,11 @@ module TurnKit
92
92
  @sections = Array(sections || prompt_sections_for_mode)
93
93
  end
94
94
 
95
- # The prompt splits into a stable part (identical across turns of the same
96
- # agent, safe to cache) and a dynamic part (subject, live context,
97
- # environment) recomputed each turn. Adapters that support prompt caching
98
- # receive both via ModelRequest#instructions and #dynamic_instructions.
95
+ # The prompt splits into stable instructions and dynamic data (subject,
96
+ # live context, environment), recomputed each request. The environment is
97
+ # anchored at turn.started_at. Clients receive both separately, unless they
98
+ # opt into durable dynamic-context history. Stable sections can still change
99
+ # when the agent's tools, skills, or configuration change.
99
100
  def stable
100
101
  parts.fetch(0).join("\n\n")
101
102
  end
@@ -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
@@ -121,7 +125,7 @@ module TurnKit
121
125
  end
122
126
 
123
127
  def preview
124
- model_request
128
+ model_request(persist_context: false)
125
129
  end
126
130
 
127
131
  def status
@@ -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
@@ -365,7 +380,7 @@ module TurnKit
365
380
  heartbeat&.value
366
381
  end
367
382
 
368
- def model_request
383
+ def model_request(persist_context: true)
369
384
  prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
370
385
  instructions, dynamic_instructions = case agent.system_prompt
371
386
  when nil
@@ -375,15 +390,28 @@ module TurnKit
375
390
  else
376
391
  [ agent.system_prompt.call(prompt).to_s, nil ]
377
392
  end
393
+ client = agent.effective_client
394
+ context_in_history = client.respond_to?(:dynamic_context_in_history?) && client.dynamic_context_in_history?(model: model)
395
+ messages = llm_messages(include_dynamic_context: context_in_history)
396
+ if context_in_history
397
+ # Compare only model-visible snapshots: compaction can remove the
398
+ # previous one. Store before dispatch so worker recovery replays it.
399
+ previous = TurnKit::Compaction.project(conversation.messages_for_turn(self)).reverse.find { |message| message.kind == "dynamic_context" }
400
+ if previous ? previous.text != dynamic_instructions.to_s : !dynamic_instructions.to_s.empty?
401
+ conversation.append_message(role: "user", kind: "dynamic_context", text: dynamic_instructions.to_s, turn_id: id) if persist_context
402
+ messages << MessageProjection.dynamic_context(dynamic_instructions.to_s)
403
+ end
404
+ dynamic_instructions = nil
405
+ end
378
406
  ModelRequest.new(
379
407
  model: model,
380
- messages: llm_messages,
408
+ messages: messages,
381
409
  tools: agent.effective_tools(turn: self),
382
410
  instructions: instructions,
383
411
  dynamic_instructions: dynamic_instructions,
384
412
  thinking: thinking,
385
413
  output_schema: output_schema,
386
- metadata: { turn_id: id, conversation_id: conversation.id },
414
+ metadata: { turn_id: id, conversation_id: conversation.id, request_id: @record.dig("options", "state", "request_id") },
387
415
  report: prompt.report
388
416
  )
389
417
  end
@@ -411,8 +439,23 @@ module TurnKit
411
439
  with_heartbeat { client.view_media(**request, on_event: ->(event) { emit_event(event) }) }
412
440
  end
413
441
 
414
- def llm_messages
415
- MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
442
+ def llm_messages(include_dynamic_context: false)
443
+ messages = TurnKit::Compaction.project(conversation.messages_for_turn(self))
444
+ # Delivery time is not application time. A next-turn message can arrive
445
+ # between an earlier turn's tool call/result or before its steering.
446
+ # Keep UI sequence order intact, but place each delivery at the frozen
447
+ # context boundary of its first receiving turn in the provider input.
448
+ turns = store.list_turns(conversation_id: conversation.id)
449
+ messages = messages.sort_by do |message|
450
+ delivery_id = message.metadata["delivery_id"]
451
+ receiver = if delivery_id
452
+ turns.find { |row| row.dig("options", "state", "delivery_requests", delivery_id) } ||
453
+ turns.find { |row| row["context_message_sequence"] >= message.sequence &&
454
+ (row["submitted_at"] || row["started_at"] || row["id"] == id) }
455
+ end
456
+ receiver ? [receiver.fetch("context_message_sequence"), 1, message.sequence] : [message.sequence, 0, 0]
457
+ end
458
+ MessageProjection.for(messages, include_dynamic_context: include_dynamic_context)
416
459
  end
417
460
 
418
461
  def emit_model_requested(type, request)
@@ -475,7 +518,7 @@ module TurnKit
475
518
  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
519
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
477
520
  else
478
- message = conversation.append_message(role: "assistant", kind: "text", text: result.text, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
521
+ message = conversation.append_message(role: "assistant", kind: "text", content: result.parts, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
479
522
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
480
523
  end
481
524
  end
@@ -505,10 +548,14 @@ module TurnKit
505
548
  else
506
549
  attrs[:status] = "completed"
507
550
  end
508
- store.atomic(Background.root_conversation(store, @record)) do
551
+ controlled = store.atomic(Background.root_conversation(store, @record)) do
552
+ control = control_boundary!
553
+ next control if control
509
554
  update_state!("policy_audit" => audit.to_h) if audit
510
555
  update!(attrs)
556
+ nil
511
557
  end
558
+ return if controlled
512
559
  emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length) if audit
513
560
 
514
561
  if failed?
@@ -576,7 +623,19 @@ module TurnKit
576
623
  store.atomic do
577
624
  @budget = execution_budget
578
625
  budget.count_iteration!
579
- update_state!("iterations" => Turn.iterations_for(@record) + 1)
626
+ request_id = SecureRandom.uuid
627
+ options = store.load_turn(id).fetch("options")
628
+ controls = options["controls"] || {}
629
+ inputs = controls.fetch("inputs", []).map do |input|
630
+ input["message_id"] && !input["request_id"] ? input.merge("request_id" => request_id) : input
631
+ end
632
+ update!(options: options.merge("controls" => controls.merge("inputs" => inputs)))
633
+ deliveries = options.dig("state", "delivery_requests") || {}
634
+ conversation.messages_for_turn(self).each do |message|
635
+ delivery_id = message.metadata["delivery_id"]
636
+ deliveries[delivery_id] ||= request_id if delivery_id
637
+ end
638
+ update_state!("iterations" => Turn.iterations_for(@record) + 1, "request_id" => request_id, "delivery_requests" => deliveries)
580
639
  end
581
640
  end
582
641
 
@@ -584,7 +643,7 @@ module TurnKit
584
643
  # write-once turn configuration. Reads fall back to the legacy top-level
585
644
  # keys for turns persisted before the split.
586
645
  def update_state!(changes)
587
- options = @record["options"] || {}
646
+ options = store.load_turn(id)["options"] || {}
588
647
  update!(options: options.merge("state" => (options["state"] || {}).merge(changes)))
589
648
  end
590
649
 
@@ -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.1"
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.1
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