turnkit 0.7.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 +11 -0
- data/README.md +42 -0
- data/lib/turnkit/adapters/ruby_llm.rb +19 -1
- data/lib/turnkit/client.rb +7 -0
- data/lib/turnkit/conversation.rb +2 -2
- data/lib/turnkit/message.rb +1 -1
- data/lib/turnkit/message_projection.rb +17 -2
- data/lib/turnkit/system_prompt.rb +5 -4
- data/lib/turnkit/turn.rb +18 -5
- data/lib/turnkit/version.rb +1 -1
- metadata +1 -1
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,16 @@
|
|
|
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
|
+
|
|
3
14
|
## 0.7.0 - 2026-09-10
|
|
4
15
|
|
|
5
16
|
- Add destination-oriented `Conversation#post`, durable input/request receipts,
|
data/README.md
CHANGED
|
@@ -463,6 +463,48 @@ agent = TurnKit::Agent.new(
|
|
|
463
463
|
`TurnKit.prompt_behavior`, and `TurnKit.context_contributors` remain available
|
|
464
464
|
for generated prompts.
|
|
465
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
|
+
|
|
466
508
|
### Tools
|
|
467
509
|
|
|
468
510
|
Create a tool:
|
|
@@ -30,6 +30,18 @@ module TurnKit
|
|
|
30
30
|
raise ModelAccessError, "#{key_name} is required for #{model}. Set ENV[#{key_name.inspect}] or configure RubyLLM before running TurnKit."
|
|
31
31
|
end
|
|
32
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
|
+
|
|
33
45
|
def chat(model:, messages:, tools:, instructions:, dynamic_instructions: nil, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
|
|
34
46
|
ensure_ruby_llm!
|
|
35
47
|
configure_from_environment
|
|
@@ -37,7 +49,8 @@ module TurnKit
|
|
|
37
49
|
validate!(model: model) if @protocol
|
|
38
50
|
chat = ::RubyLLM.chat(**{ model: model, protocol: @protocol }.compact)
|
|
39
51
|
chat.with_provider_options(metadata: metadata.transform_values(&:to_s)) if @protocol == :responses && metadata
|
|
40
|
-
|
|
52
|
+
context_in_history = chat.model.provider.to_s == "openai"
|
|
53
|
+
add_instructions(chat, instructions, context_in_history ? nil : dynamic_instructions, model: model)
|
|
41
54
|
chat.with_temperature(temperature) if temperature
|
|
42
55
|
apply_thinking(chat, thinking)
|
|
43
56
|
chat.with_schema(normalize_schema(output_schema)) if output_schema
|
|
@@ -46,6 +59,11 @@ module TurnKit
|
|
|
46
59
|
end
|
|
47
60
|
tool_names = {}
|
|
48
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
|
|
49
67
|
|
|
50
68
|
response = complete_without_tool_execution(chat)
|
|
51
69
|
normalize_response(response, model: model)
|
data/lib/turnkit/client.rb
CHANGED
|
@@ -12,6 +12,13 @@ module TurnKit
|
|
|
12
12
|
true
|
|
13
13
|
end
|
|
14
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
|
+
|
|
15
22
|
def chat(model:, messages:, tools:, instructions:, dynamic_instructions: nil, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
|
|
16
23
|
raise NotImplementedError
|
|
17
24
|
end
|
data/lib/turnkit/conversation.rb
CHANGED
|
@@ -32,8 +32,8 @@ module TurnKit
|
|
|
32
32
|
|
|
33
33
|
def messages_after(sequence, principal: nil)
|
|
34
34
|
Authorization.authorize!(:read_messages, principal: principal, destination_conversation: id)
|
|
35
|
-
messages.select { |message| message.sequence > sequence }.map do |message|
|
|
36
|
-
#
|
|
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
37
|
attrs = message.to_h
|
|
38
38
|
attrs["content"] = Array(attrs["content"]).reject { |part| %w[thinking provider].include?(part["type"]) }
|
|
39
39
|
Message.new(attrs)
|
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,6 +40,8 @@ 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 },
|
|
@@ -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/turn.rb
CHANGED
|
@@ -125,7 +125,7 @@ module TurnKit
|
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
def preview
|
|
128
|
-
model_request
|
|
128
|
+
model_request(persist_context: false)
|
|
129
129
|
end
|
|
130
130
|
|
|
131
131
|
def status
|
|
@@ -380,7 +380,7 @@ module TurnKit
|
|
|
380
380
|
heartbeat&.value
|
|
381
381
|
end
|
|
382
382
|
|
|
383
|
-
def model_request
|
|
383
|
+
def model_request(persist_context: true)
|
|
384
384
|
prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
|
|
385
385
|
instructions, dynamic_instructions = case agent.system_prompt
|
|
386
386
|
when nil
|
|
@@ -390,9 +390,22 @@ module TurnKit
|
|
|
390
390
|
else
|
|
391
391
|
[ agent.system_prompt.call(prompt).to_s, nil ]
|
|
392
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
|
|
393
406
|
ModelRequest.new(
|
|
394
407
|
model: model,
|
|
395
|
-
messages:
|
|
408
|
+
messages: messages,
|
|
396
409
|
tools: agent.effective_tools(turn: self),
|
|
397
410
|
instructions: instructions,
|
|
398
411
|
dynamic_instructions: dynamic_instructions,
|
|
@@ -426,7 +439,7 @@ module TurnKit
|
|
|
426
439
|
with_heartbeat { client.view_media(**request, on_event: ->(event) { emit_event(event) }) }
|
|
427
440
|
end
|
|
428
441
|
|
|
429
|
-
def llm_messages
|
|
442
|
+
def llm_messages(include_dynamic_context: false)
|
|
430
443
|
messages = TurnKit::Compaction.project(conversation.messages_for_turn(self))
|
|
431
444
|
# Delivery time is not application time. A next-turn message can arrive
|
|
432
445
|
# between an earlier turn's tool call/result or before its steering.
|
|
@@ -442,7 +455,7 @@ module TurnKit
|
|
|
442
455
|
end
|
|
443
456
|
receiver ? [receiver.fetch("context_message_sequence"), 1, message.sequence] : [message.sequence, 0, 0]
|
|
444
457
|
end
|
|
445
|
-
MessageProjection.for(messages)
|
|
458
|
+
MessageProjection.for(messages, include_dynamic_context: include_dynamic_context)
|
|
446
459
|
end
|
|
447
460
|
|
|
448
461
|
def emit_model_requested(type, request)
|
data/lib/turnkit/version.rb
CHANGED