turnkit 0.7.2 → 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 +16 -0
- data/README.md +48 -0
- data/lib/turnkit/agent.rb +3 -2
- data/lib/turnkit/background.rb +1 -1
- data/lib/turnkit/coordination_tools.rb +1 -1
- data/lib/turnkit/sub_agent_tool.rb +45 -25
- data/lib/turnkit/tool_runner.rb +15 -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,21 @@
|
|
|
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
|
+
|
|
3
19
|
## 0.7.2 - 2026-09-10
|
|
4
20
|
|
|
5
21
|
- Add explicit `Tool.budget_completion!` for replay-safe local terminal saves
|
data/README.md
CHANGED
|
@@ -590,6 +590,28 @@ puts turn.output_text
|
|
|
590
590
|
|
|
591
591
|
Rely on TurnKit to validate tools and model-provided arguments.
|
|
592
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
|
+
|
|
593
615
|
### Images
|
|
594
616
|
|
|
595
617
|
Generate images inside a durable turn with `turn.paint`. The image call uses the
|
|
@@ -839,6 +861,32 @@ puts turn.output_text
|
|
|
839
861
|
|
|
840
862
|
Use sub-agents for isolated child conversations.
|
|
841
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
|
+
|
|
842
890
|
#### Oracle- and Librarian-style specialists
|
|
843
891
|
|
|
844
892
|
Use ordinary agents, not a second specialist runtime. Give each specialist its
|
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)
|
|
@@ -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_runner.rb
CHANGED
|
@@ -93,6 +93,8 @@ module TurnKit
|
|
|
93
93
|
context = ToolContext.new(turn: turn, execution: execution)
|
|
94
94
|
payload = begin
|
|
95
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
|
|
96
98
|
# Observe cancellation/reconciliation immediately before crossing the
|
|
97
99
|
# external-effect boundary. Calls already sent cannot be recalled.
|
|
98
100
|
control = turn.control_boundary!
|
|
@@ -191,20 +193,30 @@ module TurnKit
|
|
|
191
193
|
end
|
|
192
194
|
|
|
193
195
|
def subagent?(tool)
|
|
194
|
-
|
|
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)
|
|
195
205
|
end
|
|
196
206
|
|
|
197
207
|
def delegate(tool, call, context)
|
|
198
|
-
|
|
208
|
+
tool = tool.new if tool.is_a?(Class)
|
|
209
|
+
arguments = tool.class.validate_arguments(call.arguments)
|
|
199
210
|
Authorization.authorize!(:launch_agent, principal: context.principal, turn: turn, agent: tool.agent, arguments: arguments)
|
|
200
211
|
TurnKit.resolve_agent(tool.agent.name)
|
|
212
|
+
task = tool.task_for(**arguments.transform_keys(&:to_sym))
|
|
201
213
|
child = turn.store.atomic_graph do
|
|
202
214
|
turn.store.atomic(Background.root_conversation(turn.store, turn.store.load_turn(turn.id))) do
|
|
203
215
|
control = turn.control_boundary!
|
|
204
216
|
next control if control
|
|
205
217
|
row = turn.store.list_turns(root_turn_id: turn.root_turn_id).find { |candidate| candidate["parent_tool_execution_id"] == context.execution.id }
|
|
206
218
|
unless row
|
|
207
|
-
built = tool.build_child(task:
|
|
219
|
+
built = tool.build_child(task: task, context: context)
|
|
208
220
|
row = turn.store.update_turn(built.id, submitted_at: Clock.now)
|
|
209
221
|
end
|
|
210
222
|
Background.wait(turn, [row.fetch("id")])
|
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,
|