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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +11 -0
- data/lib/turnkit/active_record_store.rb +1 -1
- data/lib/turnkit/adapters/ruby_llm.rb +93 -21
- data/lib/turnkit/background.rb +5 -5
- data/lib/turnkit/client.rb +3 -1
- data/lib/turnkit/conversation.rb +31 -0
- data/lib/turnkit/coordination_tools.rb +6 -0
- data/lib/turnkit/cost.rb +7 -7
- data/lib/turnkit/memory_store.rb +7 -5
- data/lib/turnkit/message_projection.rb +4 -1
- data/lib/turnkit/record.rb +1 -1
- data/lib/turnkit/run.rb +13 -0
- data/lib/turnkit/store.rb +1 -1
- data/lib/turnkit/tool_runner.rb +9 -1
- data/lib/turnkit/turn.rb +54 -8
- data/lib/turnkit/turn_controls.rb +136 -0
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a276982d34a5c7685f9512e29f1843942166c67bdb59c94ebc38b727213ffe3
|
|
4
|
+
data.tar.gz: 8ad4edfdc514c24a918441734c4c10294e7d1c45b08b7594f19db7e3294edd61
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
#
|
|
137
|
-
#
|
|
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
|
-
|
|
157
|
-
|
|
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:
|
|
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)
|
|
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:
|
|
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:
|
|
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
|
data/lib/turnkit/background.rb
CHANGED
|
@@ -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"]
|
|
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
|
data/lib/turnkit/client.rb
CHANGED
|
@@ -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
|
data/lib/turnkit/conversation.rb
CHANGED
|
@@ -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::
|
|
77
|
-
input: usage.input_tokens,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
data/lib/turnkit/memory_store.rb
CHANGED
|
@@ -58,11 +58,13 @@ module TurnKit
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def append_message(attributes)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/turnkit/record.rb
CHANGED
|
@@ -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)
|
data/lib/turnkit/tool_runner.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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 =
|
|
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
|
data/lib/turnkit/version.rb
CHANGED
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.
|
|
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-
|
|
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
|