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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +47 -1
- data/lib/turnkit/budget.rb +7 -3
- data/lib/turnkit/tool.rb +14 -0
- data/lib/turnkit/tool_runner.rb +18 -1
- data/lib/turnkit/turn.rb +26 -3
- 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: fb1f58cfea02169c68e433d8a8b84dc5eb2a8a54f18ea9e89853e4ad85445ccd
|
|
4
|
+
data.tar.gz: '085c56655a5e11670528b157926a6003a04a074944adaf7cf61730cc18c5d6a3'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/turnkit/budget.rb
CHANGED
|
@@ -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
|
|
70
|
+
raise BudgetError, "cost limit reached" if spend_exhausted?
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
def
|
|
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
|
|
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
|
data/lib/turnkit/tool_runner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/turnkit/version.rb
CHANGED