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 +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +95 -1
- data/lib/turnkit/agent.rb +3 -2
- data/lib/turnkit/background.rb +1 -1
- data/lib/turnkit/budget.rb +7 -3
- data/lib/turnkit/coordination_tools.rb +1 -1
- data/lib/turnkit/sub_agent_tool.rb +45 -25
- data/lib/turnkit/tool.rb +14 -0
- data/lib/turnkit/tool_runner.rb +33 -4
- data/lib/turnkit/turn.rb +26 -3
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +2 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ceb5e53317935ed6bb840b8b6e06157138fb95027f35ed798259a7c9bebbbcbd
|
|
4
|
+
data.tar.gz: e4c9f08152c55fa2e7619285f723fb1cba673a9760e9952b8d6792f87333ae71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
data/lib/turnkit/background.rb
CHANGED
|
@@ -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 && !
|
|
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)
|
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
|
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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.
|
|
21
|
-
|
|
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" =>
|
|
31
|
-
conversation = Conversation.new(agent:
|
|
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:
|
|
40
|
-
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
|
|
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:
|
|
55
|
-
child =
|
|
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
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
data/lib/turnkit/version.rb
CHANGED
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.
|
|
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.
|
|
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-
|
|
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,
|