turnkit 0.7.1 → 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: 170286a1c5a1975fe34234a667c946c465cb6512509be4bf06d397f48b2f9f2e
4
- data.tar.gz: 8798216dcaece0679c5f8365ee5a2af900b9660694cb3a640c1924f99b814c5d
3
+ metadata.gz: fb1f58cfea02169c68e433d8a8b84dc5eb2a8a54f18ea9e89853e4ad85445ccd
4
+ data.tar.gz: '085c56655a5e11670528b157926a6003a04a074944adaf7cf61730cc18c5d6a3'
5
5
  SHA512:
6
- metadata.gz: 2e0286f571241210786433490a66cbf54d7b2453e1202d60ada2a45ae7fa6c1c370857cf6ad96348ab1cb1e4d16bdbdf0b8a26ccc59cb7a712718f0e9e309ba8
7
- data.tar.gz: 85dcbfe855bc6937f495eddbc7a20bd32ba994035b024b8958b08cf9ce69c1ac5f98171722373462c034a8426f658788fd13c8ee49cb9d6b7954369c38a749f5
6
+ metadata.gz: 38ef2c605e8ee0d5a15417db75a6efce91b045103f68f86a241f7db788806b63ab6dbdd16cc40b1bee24b54ed93f6b83fb9331e13dbf6d06900bc79f4e7305d8
7
+ data.tar.gz: a9a752449f11422b599b9eaba4598441a295366fc4ab1dddccb136689d9279c2e8b426de9d1f2f8b45fe74011209bd22942e388033892547b5f8355a65d8e56c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## 0.7.1 - 2026-09-10
4
15
 
5
16
  - Preserve OpenAI prompt prefixes with durable, append-only dynamic context
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
@@ -1058,7 +1102,9 @@ TurnKit.timeout = 300
1058
1102
  ```
1059
1103
 
1060
1104
  `max_spend` is the only spend-limit name in the public API.
1061
-
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.
1062
1108
 
1063
1109
  Customize cost rates with USD-per-million-token component keys:
1064
1110
 
@@ -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
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
@@ -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
@@ -418,6 +427,7 @@ module TurnKit
418
427
 
419
428
  # Clients implement the TurnKit::Client keyword contract. See client.rb.
420
429
  def call_client(request, client: agent.effective_client)
430
+ execution_budget.check!(depth: depth)
421
431
  client.chat(
422
432
  model: request.model,
423
433
  messages: request.messages,
@@ -432,13 +442,26 @@ module TurnKit
432
442
  end
433
443
 
434
444
  def call_image_client(client, request)
445
+ execution_budget.check!(depth: depth)
435
446
  with_heartbeat { client.paint(**request, on_event: ->(event) { emit_event(event) }) }
436
447
  end
437
448
 
438
449
  def call_media_client(client, request)
450
+ execution_budget.check!(depth: depth)
439
451
  with_heartbeat { client.view_media(**request, on_event: ->(event) { emit_event(event) }) }
440
452
  end
441
453
 
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
+
442
465
  def llm_messages(include_dynamic_context: false)
443
466
  messages = TurnKit::Compaction.project(conversation.messages_for_turn(self))
444
467
  # Delivery time is not application time. A next-turn message can arrive
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.7.1"
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.1
4
+ version: 0.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch