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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +89 -1
- data/lib/turnkit/adapters/ruby_llm.rb +19 -1
- data/lib/turnkit/budget.rb +7 -3
- 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/tool.rb +14 -0
- data/lib/turnkit/tool_runner.rb +18 -1
- data/lib/turnkit/turn.rb +44 -8
- 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,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
|
-
|
|
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/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/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/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
|
@@ -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:
|
|
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
|
|
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)
|
data/lib/turnkit/version.rb
CHANGED