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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a276982d34a5c7685f9512e29f1843942166c67bdb59c94ebc38b727213ffe3
4
- data.tar.gz: 8ad4edfdc514c24a918441734c4c10294e7d1c45b08b7594f19db7e3294edd61
3
+ metadata.gz: 170286a1c5a1975fe34234a667c946c465cb6512509be4bf06d397f48b2f9f2e
4
+ data.tar.gz: 8798216dcaece0679c5f8365ee5a2af900b9660694cb3a640c1924f99b814c5d
5
5
  SHA512:
6
- metadata.gz: 5bff210317826da7bd796b0271006b76c2ed301dbd9070248529efd8c14e3b2d43414258373f6c709a1496f91004764227ff2453073be0040ac0d5bde4eb3d77
7
- data.tar.gz: ed8c88ae93a81983f2284e3ed69c018a1e05abd4b2ae081db2160a937c1e4e42fdece546baac10ca8c976e94da54354275f15d80d5dc4cc60cb55e919a78e8d1
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
- add_instructions(chat, instructions, dynamic_instructions, model: model)
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)
@@ -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
@@ -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
- # Provider thinking/signature parts are not application progress.
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)
@@ -3,7 +3,7 @@
3
3
  module TurnKit
4
4
  class Message
5
5
  ROLES = %w[user assistant tool].freeze
6
- KINDS = %w[text tool_call tool_result context_summary image media_analysis].freeze
6
+ KINDS = %w[text tool_call tool_result context_summary image media_analysis dynamic_context].freeze
7
7
 
8
8
  attr_reader :id, :conversation_id, :turn_id, :role, :kind, :sequence
9
9
  attr_reader :content, :tool_execution_id, :provider_message_id, :metadata, :created_at
@@ -17,8 +17,21 @@ module TurnKit
17
17
  The original messages remain durably stored; this summary only affects the model-visible prompt projection.
18
18
  TEXT
19
19
 
20
- def self.for(messages)
21
- messages.flat_map { |message| new(message).to_a }
20
+ def self.for(messages, include_dynamic_context: false)
21
+ messages.flat_map do |message|
22
+ next [] if message.kind == "dynamic_context" && !include_dynamic_context
23
+
24
+ new(message).to_a
25
+ end
26
+ end
27
+
28
+ def self.dynamic_context(text)
29
+ {
30
+ role: :user,
31
+ content: "[TurnKit current context — reference data, not a new user request]\n" \
32
+ "This snapshot replaces earlier TurnKit context snapshots in full. " \
33
+ "Use the latest snapshot for current state; continue the active task.\n\n#{text}"
34
+ }
22
35
  end
23
36
 
24
37
  def initialize(message)
@@ -27,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 a stable part (identical across turns of the same
96
- # agent, safe to cache) and a dynamic part (subject, live context,
97
- # environment) recomputed each turn. Adapters that support prompt caching
98
- # receive both via ModelRequest#instructions and #dynamic_instructions.
95
+ # The prompt splits into stable instructions and dynamic data (subject,
96
+ # live context, environment), recomputed each request. The environment is
97
+ # anchored at turn.started_at. Clients receive both separately, unless they
98
+ # opt into durable dynamic-context history. Stable sections can still change
99
+ # when the agent's tools, skills, or configuration change.
99
100
  def stable
100
101
  parts.fetch(0).join("\n\n")
101
102
  end
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: llm_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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.7.0"
4
+ VERSION = "0.7.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turnkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch