turnkit 0.7.0 → 0.7.2

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: fb1f58cfea02169c68e433d8a8b84dc5eb2a8a54f18ea9e89853e4ad85445ccd
4
+ data.tar.gz: '085c56655a5e11670528b157926a6003a04a074944adaf7cf61730cc18c5d6a3'
5
5
  SHA512:
6
- metadata.gz: 5bff210317826da7bd796b0271006b76c2ed301dbd9070248529efd8c14e3b2d43414258373f6c709a1496f91004764227ff2453073be0040ac0d5bde4eb3d77
7
- data.tar.gz: ed8c88ae93a81983f2284e3ed69c018a1e05abd4b2ae081db2160a937c1e4e42fdece546baac10ca8c976e94da54354275f15d80d5dc4cc60cb55e919a78e8d1
6
+ metadata.gz: 38ef2c605e8ee0d5a15417db75a6efce91b045103f68f86a241f7db788806b63ab6dbdd16cc40b1bee24b54ed93f6b83fb9331e13dbf6d06900bc79f4e7305d8
7
+ data.tar.gz: a9a752449f11422b599b9eaba4598441a295366fc4ab1dddccb136689d9279c2e8b426de9d1f2f8b45fe74011209bd22942e388033892547b5f8355a65d8e56c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.2 - 2026-09-10
4
+
5
+ - Add explicit `Tool.budget_completion!` for replay-safe local terminal saves
6
+ from an already-billed response that reaches the spend limit. Persist the
7
+ exact eligible call ID with the response; skip other batch calls and preserve
8
+ ordinary authorization, validation, receipts, fencing and recovery. Ambiguous
9
+ or failed saves fail without another model request.
10
+ - Treat exact spend exhaustion as a bound and recheck persisted spend before
11
+ model, media and ordinary tool dispatch. Terminal status alone does not permit
12
+ over-budget execution; the opt-in does not waive other runtime limits.
13
+
14
+ ## 0.7.1 - 2026-09-10
15
+
16
+ - Preserve OpenAI prompt prefixes with durable, append-only dynamic context
17
+ snapshots instead of recombining changing data into leading instructions.
18
+ Deduplicate unchanged snapshots across retries/resume, preserve opaque
19
+ Responses reasoning and tool pairing, and refresh full context after
20
+ compaction. Other clients retain the separate-instructions contract.
21
+ - Add the `dynamic_context` message kind (no schema migration; upgrade workers
22
+ and custom kind allowlists together). Exclude snapshots from public progress
23
+ reads. Clarify that `prompt_cache: :off` does not disable OpenAI implicit caching.
24
+
3
25
  ## 0.7.0 - 2026-09-10
4
26
 
5
27
  - Add destination-oriented `Conversation#post`, durable input/request receipts,
data/README.md CHANGED
@@ -323,6 +323,50 @@ class SaveBrief < TurnKit::Tool
323
323
  end
324
324
  ```
325
325
 
326
+ ### Saving an already-billed final response at the spend limit
327
+
328
+ By default, reaching `max_spend` stops the turn before further work. A model
329
+ response can itself reach or exceed that limit: its usage and proposed tool
330
+ calls are persisted, but an ordinary terminal tool is not allowed to run.
331
+
332
+ For a **local, idempotent final save only**, explicitly opt in on the tool:
333
+
334
+ ```ruby
335
+ class SavePacket < TurnKit::Tool
336
+ terminal! { |result| "Saved #{result.fetch('id')}." }
337
+ recovery :replay_safe
338
+ budget_completion!
339
+
340
+ # Define parameters and call normally. Validate the complete packet before
341
+ # saving; persist context.idempotency_key with the save in one transaction.
342
+ end
343
+ ```
344
+
345
+ `budget_completion!` requires both terminal behavior and `recovery :replay_safe`.
346
+ It is an application promise that the tool only validates/saves already acquired
347
+ output locally: no model calls, acquisition, or child launches. Terminal status
348
+ alone never grants this exception, and the marker is not inherited implicitly.
349
+
350
+ When the billed response reaches the spend limit, TurnKit atomically records the
351
+ sole eligible call ID with that response. The normal tool runner executes only
352
+ that call; other calls in the batch get skipped receipts and paired tool results,
353
+ even if they precede the save. Zero or multiple eligible calls fail without tool
354
+ dispatch. No new model request is allowed in the exhausted turn, including
355
+ compaction, model-backed output audits, image generation, or media analysis.
356
+
357
+ Authorization, argument validation, claim fencing, cancellation, timeout/depth,
358
+ and global/per-tool execution limits still apply. Invalid saves must raise
359
+ `ToolValidationError`/`ToolError`; an ordinary error-shaped hash is still tool
360
+ result data. Failed receipts are never replayed or repaired with another model
361
+ call. Failed local output audits also terminate rather than request revision;
362
+ model-backed audits cannot run at exhaustion. Use local validation for this path.
363
+
364
+ Worker recovery reuses the persisted call/execution and its idempotency key;
365
+ completed receipts are not re-executed. The application must make the save and
366
+ its receipt atomic. An already-started external effect cannot be recalled by
367
+ cancellation, so this marker must not be applied to acquisition tools. No schema
368
+ migration is needed; upgrade workers together before enabling the opt-in.
369
+
326
370
  ### Output audits and policies
327
371
 
328
372
  Use output audits for deterministic checks that should not depend on another
@@ -463,6 +507,48 @@ agent = TurnKit::Agent.new(
463
507
  `TurnKit.prompt_behavior`, and `TurnKit.context_contributors` remain available
464
508
  for generated prompts.
465
509
 
510
+ ### Dynamic context and OpenAI prompt caching
511
+
512
+ Generated prompts keep stable instructions separate from subject, live context,
513
+ and environment. With the RubyLLM OpenAI adapter (Responses or Chat Completions),
514
+ TurnKit persists each changed **full context snapshot** as a `dynamic_context`
515
+ conversation message before model dispatch. It replays older snapshots unchanged
516
+ and appends new ones after completed tool exchanges. They render as labeled user
517
+ reference-data messages, not new user requests or top-level instructions;
518
+ the newest snapshot replaces earlier snapshots for current state. Keep policies
519
+ in stable instructions and supply current data through context contributors.
520
+
521
+ Identical snapshots are deduplicated against durable, model-visible history,
522
+ including after retry/resume. Empty context clears a previous snapshot. Changed
523
+ context adds history, so keep contributors concise; compaction can remove old
524
+ snapshots and resets that portion of the prefix. Always supply the full current
525
+ context, not deltas. Changing tools, skills, schemas, model settings, or stable
526
+ instructions can also invalidate a prefix. Existing histories are not rewritten.
527
+
528
+ Custom clients retain the separate `instructions` / `dynamic_instructions`
529
+ contract. Clients opting into `dynamic_context_in_history?(model:)` receive
530
+ snapshots in `messages` and empty `dynamic_instructions`. Other clients do not
531
+ receive these historical snapshot messages. Raw `Conversation#messages` includes
532
+ them; public `messages_after` progress reads exclude them. No schema migration is
533
+ needed, but custom message-kind allowlists must accept `dynamic_context`, and
534
+ workers reading those conversations must be upgraded together.
535
+
536
+ Direct `adapter.chat` callers still receive fresh dynamic context at the tail,
537
+ but must preserve prior snapshots themselves (using
538
+ `TurnKit::MessageProjection.dynamic_context(text)` in their own history) and
539
+ avoid supplying the same snapshot again through `dynamic_instructions`.
540
+ A custom `system_prompt:` string/callable is treated entirely as stable
541
+ instructions; recombining `prompt.dynamic` there bypasses this protection.
542
+
543
+ `TurnKit.prompt_cache = :auto` enables TurnKit's existing Anthropic cache markers;
544
+ `:off` suppresses those markers. It is **not an OpenAI cache-disable switch**.
545
+ OpenAI implicit caching remains provider-default in either setting, including
546
+ when RubyLLM uses `store: false` or a fresh chat object. TurnKit does not force
547
+ explicit breakpoints, cache keys, or TTLs. Matching wire prefixes make reuse
548
+ possible, not guaranteed: eligibility, routing, lifetime, and model-specific
549
+ boundaries still matter. See [OpenAI's prompt-caching guide](https://developers.openai.com/api/docs/guides/prompt-caching)
550
+ and measure actual cache reads/writes before claiming savings.
551
+
466
552
  ### Tools
467
553
 
468
554
  Create a tool:
@@ -1016,7 +1102,9 @@ TurnKit.timeout = 300
1016
1102
  ```
1017
1103
 
1018
1104
  `max_spend` is the only spend-limit name in the public API.
1019
-
1105
+ Spend equal to the limit is exhausted, not just spend above it. Dispatch checks
1106
+ use persisted aggregate spend, including after recovery and before internal
1107
+ model/media calls. Committed response cost is retained even when the turn fails.
1020
1108
 
1021
1109
  Customize cost rates with USD-per-million-token component keys:
1022
1110
 
@@ -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)
@@ -67,14 +67,18 @@ module TurnKit
67
67
 
68
68
  @mutex.synchronize do
69
69
  @cost += cost.to_f
70
- raise BudgetError, "cost limit reached" if @cost > max_spend
70
+ raise BudgetError, "cost limit reached" if spend_exhausted?
71
71
  end
72
72
  end
73
73
 
74
- def check!(depth:)
74
+ def spend_exhausted?
75
+ max_spend && @cost >= max_spend
76
+ end
77
+
78
+ def check!(depth:, allow_exhausted_spend: false)
75
79
  raise BudgetError, "maximum sub-agent depth reached" if max_depth && depth > max_depth
76
80
  raise BudgetError, "turn timed out" if timeout && Clock.now >= root_started_at + timeout
77
- raise BudgetError, "cost limit reached" if max_spend && @cost > max_spend
81
+ raise BudgetError, "cost limit reached" if spend_exhausted? && !allow_exhausted_spend
78
82
  end
79
83
 
80
84
  private
@@ -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/tool.rb CHANGED
@@ -54,6 +54,16 @@ module TurnKit
54
54
  @ends_turn || false
55
55
  end
56
56
 
57
+ # Explicit application promise: this terminal tool only validates/saves
58
+ # already acquired output locally, without model calls or acquisition.
59
+ def budget_completion!
60
+ @budget_completion = true
61
+ end
62
+
63
+ def budget_completion?
64
+ @budget_completion || false
65
+ end
66
+
57
67
  # :unknown is the safe default for external effects. :replay_safe means
58
68
  # the application/tool honors ToolContext#idempotency_key on retries.
59
69
  def recovery(value = nil)
@@ -79,6 +89,9 @@ module TurnKit
79
89
  def validate_definition!
80
90
  raise ArgumentError, "tool name is required" if tool_name.empty?
81
91
  raise ArgumentError, "invalid tool name: #{tool_name}" unless NAME_PATTERN.match?(tool_name)
92
+ if budget_completion? && (!ends_turn? || recovery != :replay_safe)
93
+ raise ArgumentError, "budget_completion! requires a terminal tool with recovery :replay_safe"
94
+ end
82
95
 
83
96
  parameters.each do |param|
84
97
  type = param.fetch(:type)
@@ -188,6 +201,7 @@ module TurnKit
188
201
  def input_schema = self.class.input_schema
189
202
  def validate_definition! = self.class.validate_definition!
190
203
  def ends_turn? = self.class.ends_turn?
204
+ def budget_completion? = self.class.budget_completion?
191
205
  def completion_message(result) = self.class.completion_message(result)
192
206
  end
193
207
  end
@@ -7,6 +7,20 @@ module TurnKit
7
7
  end
8
8
 
9
9
  def dispatch(tool_calls)
10
+ completion_id = turn.budget_completion_call_id
11
+ if completion_id
12
+ control = turn.control_boundary!
13
+ return control if control
14
+ selected = tool_calls.select { |call| call.id == completion_id }
15
+ tool = tool_for(selected.first&.name)
16
+ unless selected.length == 1 && tool&.budget_completion? && tool.ends_turn?
17
+ raise BudgetError, "budget completion is no longer available"
18
+ end
19
+ # Preserve pairing and receipts, regardless of where acquisition calls
20
+ # appear in the response. Only the durably selected call may execute.
21
+ skip_remaining(tool_calls - selected, terminal: selected.first)
22
+ tool_calls = selected
23
+ end
10
24
  waiting = false
11
25
  tool_calls.each_with_index do |tool_call, index|
12
26
  control = turn.control_boundary!
@@ -24,6 +38,7 @@ module TurnKit
24
38
  skip_remaining(tool_calls.drop(index + 1), terminal: tool_call)
25
39
  return execution
26
40
  end
41
+ raise BudgetError, "budget completion failed" if completion_id
27
42
  end
28
43
  waiting ? :waiting : nil
29
44
  end
@@ -82,6 +97,7 @@ module TurnKit
82
97
  # external-effect boundary. Calls already sent cannot be recalled.
83
98
  control = turn.control_boundary!
84
99
  return control if control
100
+ turn.execution_budget.check!(depth: turn.depth, allow_exhausted_spend: turn.budget_completion_call_id == tool_call.id)
85
101
  if turn.background? && subagent?(tool)
86
102
  return delegate(tool, tool_call, context)
87
103
  end
@@ -164,7 +180,8 @@ module TurnKit
164
180
  calls.each do |call|
165
181
  turn.store.atomic do
166
182
  next if turn.store.list_tool_executions(turn_id: turn.id).any? { |row| row["tool_call_id"] == call.id }
167
- payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
183
+ reason = turn.budget_completion_call_id ? "spend limit reached" : "turn ended by #{terminal.name}"
184
+ payload = { "skipped" => true, "message" => "not executed: #{reason}" }
168
185
  execution = ToolExecution.new(create_execution(call))
169
186
  attrs = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "cancelled", result: payload, completed_at: Clock.now)
170
187
  append_result_once(ToolExecution.new(attrs), call, payload)
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
@@ -151,6 +151,10 @@ module TurnKit
151
151
  options.dig("state", "policy_audit") || options["policy_audit"]
152
152
  end
153
153
 
154
+ def budget_completion_call_id
155
+ @record.dig("options", "state", "budget_completion_call_id")
156
+ end
157
+
154
158
  # Reads iterations from options["state"], falling back to the legacy
155
159
  # top-level key for turns persisted before the state split.
156
160
  def self.iterations_for(record)
@@ -188,6 +192,7 @@ module TurnKit
188
192
  end
189
193
 
190
194
  def internal_model_call(model:, messages:, instructions:, tools: [], thinking: nil, output_schema: nil, metadata: {}, purpose:, client: nil)
195
+ execution_budget.check!(depth: depth)
191
196
  request = ModelRequest.new(
192
197
  model: model,
193
198
  messages: messages,
@@ -303,8 +308,8 @@ module TurnKit
303
308
  break
304
309
  end
305
310
  @budget = execution_budget
306
- budget.check!(depth: depth)
307
311
  state = @record.dig("options", "state") || {}
312
+ budget.check!(depth: depth, allow_exhausted_spend: budget_completion_call_id && %w[tools output].include?(state["phase"]))
308
313
  case state["phase"] || "model"
309
314
  when "model"
310
315
  count_iteration!
@@ -320,10 +325,11 @@ module TurnKit
320
325
  add_usage!(result.usage, cost: cost)
321
326
  persist_assistant_message(result)
322
327
  update_state!("phase" => result.tool_calls? ? "tools" : "output", "parts" => result.parts,
323
- "candidate" => result.text, "output_data" => result.output_data, "terminal_tool_name" => nil)
328
+ "candidate" => result.text, "output_data" => result.output_data, "terminal_tool_name" => nil,
329
+ "budget_completion_call_id" => select_budget_completion(result))
324
330
  end
325
331
  emit_model_completed("model.completed", result, cost, model: model)
326
- budget.add_cost!(cost.total)
332
+ budget.add_cost!(cost.total) unless budget_completion_call_id
327
333
  when "tools"
328
334
  runner = ToolRunner.new(self)
329
335
  terminal = runner.dispatch(Result.new(parts: state.fetch("parts")).tool_calls)
@@ -344,6 +350,9 @@ module TurnKit
344
350
  when "output"
345
351
  candidate = state.fetch("candidate")
346
352
  audit = check_policy(candidate, output_data: state["output_data"])
353
+ if budget_completion_call_id && audit && !audit.clean?
354
+ raise BudgetError, "budget completion rejected: #{audit.messages.join('; ')}"
355
+ end
347
356
  revisions_used = state["revisions_used"].to_i
348
357
  if should_revise?(audit, revisions_used)
349
358
  store.atomic do
@@ -380,7 +389,7 @@ module TurnKit
380
389
  heartbeat&.value
381
390
  end
382
391
 
383
- def model_request
392
+ def model_request(persist_context: true)
384
393
  prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
385
394
  instructions, dynamic_instructions = case agent.system_prompt
386
395
  when nil
@@ -390,9 +399,22 @@ module TurnKit
390
399
  else
391
400
  [ agent.system_prompt.call(prompt).to_s, nil ]
392
401
  end
402
+ client = agent.effective_client
403
+ context_in_history = client.respond_to?(:dynamic_context_in_history?) && client.dynamic_context_in_history?(model: model)
404
+ messages = llm_messages(include_dynamic_context: context_in_history)
405
+ if context_in_history
406
+ # Compare only model-visible snapshots: compaction can remove the
407
+ # previous one. Store before dispatch so worker recovery replays it.
408
+ previous = TurnKit::Compaction.project(conversation.messages_for_turn(self)).reverse.find { |message| message.kind == "dynamic_context" }
409
+ if previous ? previous.text != dynamic_instructions.to_s : !dynamic_instructions.to_s.empty?
410
+ conversation.append_message(role: "user", kind: "dynamic_context", text: dynamic_instructions.to_s, turn_id: id) if persist_context
411
+ messages << MessageProjection.dynamic_context(dynamic_instructions.to_s)
412
+ end
413
+ dynamic_instructions = nil
414
+ end
393
415
  ModelRequest.new(
394
416
  model: model,
395
- messages: llm_messages,
417
+ messages: messages,
396
418
  tools: agent.effective_tools(turn: self),
397
419
  instructions: instructions,
398
420
  dynamic_instructions: dynamic_instructions,
@@ -405,6 +427,7 @@ module TurnKit
405
427
 
406
428
  # Clients implement the TurnKit::Client keyword contract. See client.rb.
407
429
  def call_client(request, client: agent.effective_client)
430
+ execution_budget.check!(depth: depth)
408
431
  client.chat(
409
432
  model: request.model,
410
433
  messages: request.messages,
@@ -419,14 +442,27 @@ module TurnKit
419
442
  end
420
443
 
421
444
  def call_image_client(client, request)
445
+ execution_budget.check!(depth: depth)
422
446
  with_heartbeat { client.paint(**request, on_event: ->(event) { emit_event(event) }) }
423
447
  end
424
448
 
425
449
  def call_media_client(client, request)
450
+ execution_budget.check!(depth: depth)
426
451
  with_heartbeat { client.view_media(**request, on_event: ->(event) { emit_event(event) }) }
427
452
  end
428
453
 
429
- def llm_messages
454
+ def select_budget_completion(result)
455
+ return unless execution_budget.spend_exhausted?
456
+
457
+ tools = agent.effective_tools(turn: self)
458
+ candidates = result.tool_calls.select do |call|
459
+ tool = tools.find { |candidate| candidate.tool_name == call.name }
460
+ tool&.budget_completion? && tool.ends_turn?
461
+ end
462
+ candidates.first.id if candidates.length == 1
463
+ end
464
+
465
+ def llm_messages(include_dynamic_context: false)
430
466
  messages = TurnKit::Compaction.project(conversation.messages_for_turn(self))
431
467
  # Delivery time is not application time. A next-turn message can arrive
432
468
  # between an earlier turn's tool call/result or before its steering.
@@ -442,7 +478,7 @@ module TurnKit
442
478
  end
443
479
  receiver ? [receiver.fetch("context_message_sequence"), 1, message.sequence] : [message.sequence, 0, 0]
444
480
  end
445
- MessageProjection.for(messages)
481
+ MessageProjection.for(messages, include_dynamic_context: include_dynamic_context)
446
482
  end
447
483
 
448
484
  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.2"
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch