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 +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +53 -0
- data/lib/turnkit/active_record_store.rb +1 -1
- data/lib/turnkit/adapters/ruby_llm.rb +112 -22
- data/lib/turnkit/background.rb +5 -5
- data/lib/turnkit/client.rb +10 -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.rb +1 -1
- data/lib/turnkit/message_projection.rb +21 -3
- data/lib/turnkit/record.rb +1 -1
- data/lib/turnkit/run.rb +13 -0
- data/lib/turnkit/store.rb +1 -1
- data/lib/turnkit/system_prompt.rb +5 -4
- data/lib/turnkit/tool_runner.rb +9 -1
- data/lib/turnkit/turn.rb +71 -12
- 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: 170286a1c5a1975fe34234a667c946c465cb6512509be4bf06d397f48b2f9f2e
|
|
4
|
+
data.tar.gz: 8798216dcaece0679c5f8365ee5a2af900b9660694cb3a640c1924f99b814c5d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
|
|
157
|
-
|
|
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:
|
|
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)
|
|
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:
|
|
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:
|
|
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
|
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,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
|
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 && 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::
|
|
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)
|
data/lib/turnkit/message.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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)
|
|
@@ -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
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
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
|
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
|
|
@@ -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:
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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 =
|
|
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
|
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.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-
|
|
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
|