turnkit 0.7.1 → 0.8.0

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: ceb5e53317935ed6bb840b8b6e06157138fb95027f35ed798259a7c9bebbbcbd
4
+ data.tar.gz: e4c9f08152c55fa2e7619285f723fb1cba673a9760e9952b8d6792f87333ae71
5
5
  SHA512:
6
- metadata.gz: 2e0286f571241210786433490a66cbf54d7b2453e1202d60ada2a45ae7fa6c1c370857cf6ad96348ab1cb1e4d16bdbdf0b8a26ccc59cb7a712718f0e9e309ba8
7
- data.tar.gz: 85dcbfe855bc6937f495eddbc7a20bd32ba994035b024b8958b08cf9ce69c1ac5f98171722373462c034a8426f658788fd13c8ee49cb9d6b7954369c38a749f5
6
+ metadata.gz: eb629f09b9566f782014dbefaaf98a2e238c9cd39728628360a09147f117d44a043c513f8191ad1f89cd9b63c428b52fef27679d28d3a02737d64bff8762b1cc
7
+ data.tar.gz: 290d3a6c992c30deac5923f7c74a86f25b5fcf8fef851a84ccceae4f72e9fe3f32d9768efff08b465c2c2b4144140306b2182c9ef4168aa2f54e8555805206d7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 - 2026-09-18
4
+
5
+ - Add typed delegation to `SubAgentTool`: subclasses declare their own
6
+ parameters, an `agent` class macro (or instance `agent`), and `task_for` to
7
+ build the child task inside the runtime so bulk data never enters the parent
8
+ context. Emit `sub_agent.delegated` with `task_chars` per delegation, and
9
+ auto-register agents owned by sub-agent tools alongside `sub_agents`.
10
+ - Add `Agent#tool_policy`, a per-agent routing/cost gate that runs after
11
+ authorization and returns `:allow` or `[:block, reason]`; blocked calls return
12
+ the reason to the model with `details["tool_policy_blocked"]`.
13
+ - Add `examples/shunt`, a Spotify Portal-style context router with
14
+ `bulk_read`/`code_write` worker agents, a large-file `read_file` gate, and
15
+ measured savings.
16
+ - Breaking: `SubAgentTool#build_child` is an instance method, and the `task`
17
+ parameter is declared only on `SubAgentTool.for` classes.
18
+
19
+ ## 0.7.2 - 2026-09-10
20
+
21
+ - Add explicit `Tool.budget_completion!` for replay-safe local terminal saves
22
+ from an already-billed response that reaches the spend limit. Persist the
23
+ exact eligible call ID with the response; skip other batch calls and preserve
24
+ ordinary authorization, validation, receipts, fencing and recovery. Ambiguous
25
+ or failed saves fail without another model request.
26
+ - Treat exact spend exhaustion as a bound and recheck persisted spend before
27
+ model, media and ordinary tool dispatch. Terminal status alone does not permit
28
+ over-budget execution; the opt-in does not waive other runtime limits.
29
+
3
30
  ## 0.7.1 - 2026-09-10
4
31
 
5
32
  - 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
@@ -546,6 +590,28 @@ puts turn.output_text
546
590
 
547
591
  Rely on TurnKit to validate tools and model-provided arguments.
548
592
 
593
+ #### Tool policies
594
+
595
+ Gate tool calls per agent for routing or cost, separately from identity
596
+ authorization:
597
+
598
+ ```ruby
599
+ agent = TurnKit::Agent.new(
600
+ name: "reporter",
601
+ tools: [ReadFile, BulkRead],
602
+ tool_policy: lambda do |tool:, arguments:, context:|
603
+ next :allow unless tool.is_a?(ReadFile) && File.foreach(arguments["path"]).count > 350
604
+ [:block, "File is large. Use `bulk_read` with a question, or re-read with `offset`/`limit`."]
605
+ end
606
+ )
607
+ ```
608
+
609
+ The policy runs after authorization and before the tool executes. Return
610
+ `:allow` (or `nil`) to proceed, or `[:block, reason]` to return the reason to
611
+ the model as a tool error with `details["tool_policy_blocked"] = true`. Keep
612
+ `authorization_policy` for who may call what; use `tool_policy` for how much and
613
+ which way.
614
+
549
615
  ### Images
550
616
 
551
617
  Generate images inside a durable turn with `turn.paint`. The image call uses the
@@ -795,6 +861,32 @@ puts turn.output_text
795
861
 
796
862
  Use sub-agents for isolated child conversations.
797
863
 
864
+ #### Typed delegation
865
+
866
+ Subclass `SubAgentTool` to build the child task from typed arguments so bulk
867
+ data never enters the parent's context:
868
+
869
+ ```ruby
870
+ class BulkRead < TurnKit::SubAgentTool
871
+ agent reader
872
+ description "Read files and answer a question about them."
873
+
874
+ parameter :question, :string, required: true
875
+ parameter :paths, :array, required: true, items: :string
876
+
877
+ def task_for(question:, paths:)
878
+ files = paths.map { |path| "<file path=\"#{path}\">\n#{File.read(path)}\n</file>" }
879
+ "#{question}\n\n#{files.join("\n")}"
880
+ end
881
+ end
882
+ ```
883
+
884
+ The parent model supplies `question` and `paths`; `task_for` runs inside the
885
+ runtime, and only the child's answer returns to the parent. Override `agent` on
886
+ an instance when the child agent is configured at runtime. Each delegation emits
887
+ `sub_agent.delegated` with `task_chars`, so avoided parent context is
888
+ measurable. See [`examples/shunt`](examples/shunt) for a complete routing setup.
889
+
798
890
  #### Oracle- and Librarian-style specialists
799
891
 
800
892
  Use ordinary agents, not a second specialist runtime. Give each specialist its
@@ -1058,7 +1150,9 @@ TurnKit.timeout = 300
1058
1150
  ```
1059
1151
 
1060
1152
  `max_spend` is the only spend-limit name in the public API.
1061
-
1153
+ Spend equal to the limit is exhausted, not just spend above it. Dispatch checks
1154
+ use persisted aggregate spend, including after recovery and before internal
1155
+ model/media calls. Committed response cost is retained even when the turn fails.
1062
1156
 
1063
1157
  Customize cost rates with USD-per-million-token component keys:
1064
1158
 
data/lib/turnkit/agent.rb CHANGED
@@ -24,10 +24,10 @@ module TurnKit
24
24
  attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
25
25
  attr_reader :client, :store, :max_iterations, :timeout, :max_spend, :max_depth, :max_tool_executions, :max_tool_executions_by_name
26
26
  attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :input_schema, :on_event
27
- attr_reader :output_policy, :output_policy_mode, :output_policy_model, :output_retries, :context_contributors
27
+ attr_reader :output_policy, :output_policy_mode, :output_policy_model, :output_retries, :context_contributors, :tool_policy
28
28
 
29
29
  def initialize(name:, description: "", model: nil, instructions: "", orchestrator: false, tools: [], skills: [], available_skills: [], sub_agents: [],
30
- system_prompt: nil, prompt_sections: nil, prompt_mode: nil, client: nil, store: nil,
30
+ tool_policy: nil, system_prompt: nil, prompt_sections: nil, prompt_mode: nil, client: nil, store: nil,
31
31
  max_iterations: nil, timeout: nil, max_spend: nil, max_depth: nil, max_tool_executions: nil, max_tool_executions_by_name: nil, thinking: nil, compaction: nil,
32
32
  output_schema: nil, input_schema: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, output_retries: 0, on_event: nil, context_contributors: [], inherit_globals: true)
33
33
  @name = name.to_s
@@ -39,6 +39,7 @@ module TurnKit
39
39
  @skills = Array(skills).dup.freeze
40
40
  @available_skills = ((inherit_globals ? Array(TurnKit.available_skills) : []) + Array(available_skills)).uniq { |skill| skill.key }.freeze
41
41
  @sub_agents = Array(sub_agents).dup.freeze
42
+ @tool_policy = tool_policy
42
43
  @system_prompt = system_prompt
43
44
  @prompt_sections = prompt_sections
44
45
  @prompt_mode = prompt_mode&.to_sym || (:task if @orchestrator)
@@ -264,7 +264,7 @@ module TurnKit
264
264
  loaded = Background.load_turn(record.fetch("id"), store: store)
265
265
  tool = loaded.agent.effective_tools(turn: loaded).find { |candidate| candidate.tool_name == execution["tool_name"] }
266
266
  recovery = tool.is_a?(Class) ? tool.recovery : tool&.class&.recovery
267
- ordinary = tool && !(tool.is_a?(Class) && tool < SubAgentTool) &&
267
+ ordinary = tool && !SubAgentTool.delegates?(tool) &&
268
268
  ![WaitTool, LaunchAgentTool, SendMessageTool].include?(tool)
269
269
  if ordinary && recovery == :replay_safe
270
270
  store.claim_tool_execution(execution.fetch("id"), to: "pending", started_at: nil)
@@ -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
@@ -39,7 +39,7 @@ module TurnKit
39
39
  if existing
40
40
  child = existing
41
41
  else
42
- built = SubAgentTool.for(agent).build_child(task: task, context: context)
42
+ built = SubAgentTool.for(agent).new.build_child(task: task, context: context)
43
43
  options = parent.store.load_turn(built.id).fetch("options")
44
44
  options = options.merge("callback_conversation_id" => parent.conversation.id) if callback
45
45
  child = parent.store.update_turn(built.id, submitted_at: Clock.now, options: options)
@@ -1,24 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
+ # Runs a child agent in a fresh conversation and returns only its final result.
5
+ # `SubAgentTool.for(agent)` exposes an agent as a tool taking `task`. Subclasses
6
+ # declare their own parameters and override `task_for` to assemble the task in
7
+ # Ruby, so bulk data (file contents, records) reaches the child without ever
8
+ # entering the parent model's context. The child comes from the `agent` class
9
+ # macro or an instance-level `agent` override.
4
10
  class SubAgentTool < Tool
5
- parameter :task, :string, required: true, description: "The complete task for the sub-agent, including all relevant context."
6
-
7
- def self.for(agent)
8
- Class.new(self) do
9
- @agent = agent
10
- tool_name agent.name
11
- description agent.description.empty? ? "Delegate work to #{agent.name}." : agent.description
12
- usage_hint "Use when work can be delegated independently to #{agent.name}. Pass a complete task and only relevant context."
11
+ class << self
12
+ def agent(value = nil)
13
+ @agent = value if value
14
+ @agent || (superclass < SubAgentTool ? superclass.agent : nil)
15
+ end
13
16
 
14
- class << self
15
- attr_reader :agent
17
+ def for(agent)
18
+ sub_agent = agent
19
+ Class.new(self) do
20
+ agent sub_agent
21
+ tool_name sub_agent.name
22
+ description sub_agent.description.empty? ? "Delegate work to #{sub_agent.name}." : sub_agent.description
23
+ usage_hint "Use when work can be delegated independently to #{sub_agent.name}. Pass a complete task and only relevant context."
24
+ parameter :task, :string, required: true, description: "The complete task for the sub-agent, including all relevant context."
16
25
  end
17
26
  end
27
+
28
+ def delegates?(tool)
29
+ tool.is_a?(self) || (tool.is_a?(Class) && tool <= self)
30
+ end
31
+
32
+ def result(record)
33
+ { "conversation_id" => record.fetch("conversation_id"), "turn_id" => record.fetch("id"),
34
+ "status" => record.fetch("status"), "result" => record["output_text"].to_s,
35
+ "output_data" => record["output_data"], "error" => record["error"] }.compact
36
+ end
18
37
  end
19
38
 
20
- def self.build_child(task:, context:)
21
- sub_agent = agent
39
+ def agent = self.class.agent
40
+
41
+ def task_for(**arguments)
42
+ arguments.fetch(:task)
43
+ end
44
+
45
+ def build_child(task:, context:)
22
46
  parent_turn = context.turn
23
47
  lineage = {
24
48
  "parent_conversation_id" => parent_turn.conversation.id,
@@ -27,32 +51,28 @@ module TurnKit
27
51
  "principal" => context.principal
28
52
  }
29
53
  store = parent_turn.store
30
- record = store.create_conversation("agent_name" => sub_agent.name, "model" => sub_agent.effective_model, "metadata" => lineage)
31
- conversation = Conversation.new(agent: sub_agent, record: record, store: store, model: sub_agent.effective_model, metadata: lineage)
54
+ record = store.create_conversation("agent_name" => agent.name, "model" => agent.effective_model, "metadata" => lineage)
55
+ conversation = Conversation.new(agent: agent, record: record, store: store, model: agent.effective_model, metadata: lineage)
32
56
  trigger = conversation.say(task, metadata: lineage)
57
+ parent_turn.emit("sub_agent.delegated", id: context.execution.tool_call_id, name: agent.name,
58
+ conversation_id: record.fetch("id"), task_chars: task.length)
33
59
  conversation.build_turn(
34
60
  trigger_message_id: trigger.id,
35
61
  budget: parent_turn.budget,
36
62
  parent_turn: parent_turn,
37
63
  parent_tool_execution: context.execution,
38
64
  depth: parent_turn.depth + 1,
39
- model: sub_agent.effective_model,
40
- agent: sub_agent,
65
+ model: agent.effective_model,
66
+ agent: agent,
41
67
  principal: context.principal,
42
68
  on_event: parent_turn.agent.effective_on_event
43
69
  )
44
70
  end
45
71
 
46
- def self.result(record)
47
- { "conversation_id" => record.fetch("conversation_id"), "turn_id" => record.fetch("id"),
48
- "status" => record.fetch("status"), "result" => record["output_text"].to_s,
49
- "output_data" => record["output_data"], "error" => record["error"] }.compact
50
- end
51
-
52
- def call(task:, context:)
72
+ def call(context:, **arguments)
53
73
  Authorization.authorize!(:launch_agent, principal: context.principal, turn: context.turn,
54
- agent: self.class.agent, arguments: { "task" => task })
55
- child = self.class.build_child(task: task, context: context)
74
+ agent: agent, arguments: arguments.transform_keys(&:to_s))
75
+ child = build_child(task: task_for(**arguments), context: context)
56
76
  child.run!
57
77
  SubAgentTool.result(child.store.load_turn(child.id))
58
78
  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
@@ -78,10 +93,13 @@ module TurnKit
78
93
  context = ToolContext.new(turn: turn, execution: execution)
79
94
  payload = begin
80
95
  Authorization.authorize!(:tool, principal: context.principal, turn: turn, tool: tool, arguments: tool_call.arguments)
96
+ blocked = tool_policy_block(tool, tool_call.arguments, context)
97
+ return finish_error(execution, tool_call, blocked, details: { "tool_policy_blocked" => true }) if blocked
81
98
  # Observe cancellation/reconciliation immediately before crossing the
82
99
  # external-effect boundary. Calls already sent cannot be recalled.
83
100
  control = turn.control_boundary!
84
101
  return control if control
102
+ turn.execution_budget.check!(depth: turn.depth, allow_exhausted_spend: turn.budget_completion_call_id == tool_call.id)
85
103
  if turn.background? && subagent?(tool)
86
104
  return delegate(tool, tool_call, context)
87
105
  end
@@ -164,7 +182,8 @@ module TurnKit
164
182
  calls.each do |call|
165
183
  turn.store.atomic do
166
184
  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}" }
185
+ reason = turn.budget_completion_call_id ? "spend limit reached" : "turn ended by #{terminal.name}"
186
+ payload = { "skipped" => true, "message" => "not executed: #{reason}" }
168
187
  execution = ToolExecution.new(create_execution(call))
169
188
  attrs = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "cancelled", result: payload, completed_at: Clock.now)
170
189
  append_result_once(ToolExecution.new(attrs), call, payload)
@@ -174,20 +193,30 @@ module TurnKit
174
193
  end
175
194
 
176
195
  def subagent?(tool)
177
- tool.is_a?(Class) && tool < SubAgentTool
196
+ SubAgentTool.delegates?(tool)
197
+ end
198
+
199
+ # Agent-owned routing/cost policy, distinct from identity authorization.
200
+ # Returns the block reason the model sees, or nil to proceed.
201
+ def tool_policy_block(tool, arguments, context)
202
+ decision, reason = turn.agent.tool_policy&.call(tool: tool, arguments: arguments, context: context)
203
+ return reason.to_s if decision == :block
204
+ raise ArgumentError, "tool_policy must return :allow or [:block, reason]" unless [ nil, :allow ].include?(decision)
178
205
  end
179
206
 
180
207
  def delegate(tool, call, context)
181
- arguments = tool.validate_arguments(call.arguments)
208
+ tool = tool.new if tool.is_a?(Class)
209
+ arguments = tool.class.validate_arguments(call.arguments)
182
210
  Authorization.authorize!(:launch_agent, principal: context.principal, turn: turn, agent: tool.agent, arguments: arguments)
183
211
  TurnKit.resolve_agent(tool.agent.name)
212
+ task = tool.task_for(**arguments.transform_keys(&:to_sym))
184
213
  child = turn.store.atomic_graph do
185
214
  turn.store.atomic(Background.root_conversation(turn.store, turn.store.load_turn(turn.id))) do
186
215
  control = turn.control_boundary!
187
216
  next control if control
188
217
  row = turn.store.list_turns(root_turn_id: turn.root_turn_id).find { |candidate| candidate["parent_tool_execution_id"] == context.execution.id }
189
218
  unless row
190
- built = tool.build_child(task: arguments.fetch("task"), context: context)
219
+ built = tool.build_child(task: task, context: context)
191
220
  row = turn.store.update_turn(built.id, submitted_at: Clock.now)
192
221
  end
193
222
  Background.wait(turn, [row.fetch("id")])
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.8.0"
5
5
  end
data/lib/turnkit.rb CHANGED
@@ -78,7 +78,8 @@ module TurnKit
78
78
 
79
79
  def self.register(agent)
80
80
  @agents[agent.name] = agent
81
- agent.sub_agents.each { |child| register(child) }
81
+ tools = agent.effective_tools + agent.available_skills.flat_map(&:tools)
82
+ tools.each { |tool| register(tool.agent) if SubAgentTool.delegates?(tool) }
82
83
  agent
83
84
  end
84
85
 
metadata CHANGED
@@ -1,14 +1,14 @@
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.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Couch
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-10 00:00:00.000000000 Z
11
+ date: 2026-09-18 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: TurnKit is a Ruby/Rails agent runtime for durable AI conversations, application
14
14
  runs, orchestrator agents, tool calling, skills, sub-agents, context compaction,