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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb1f58cfea02169c68e433d8a8b84dc5eb2a8a54f18ea9e89853e4ad85445ccd
4
- data.tar.gz: '085c56655a5e11670528b157926a6003a04a074944adaf7cf61730cc18c5d6a3'
3
+ metadata.gz: ceb5e53317935ed6bb840b8b6e06157138fb95027f35ed798259a7c9bebbbcbd
4
+ data.tar.gz: e4c9f08152c55fa2e7619285f723fb1cba673a9760e9952b8d6792f87333ae71
5
5
  SHA512:
6
- metadata.gz: 38ef2c605e8ee0d5a15417db75a6efce91b045103f68f86a241f7db788806b63ab6dbdd16cc40b1bee24b54ed93f6b83fb9331e13dbf6d06900bc79f4e7305d8
7
- data.tar.gz: a9a752449f11422b599b9eaba4598441a295366fc4ab1dddccb136689d9279c2e8b426de9d1f2f8b45fe74011209bd22942e388033892547b5f8355a65d8e56c
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)
@@ -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)
@@ -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
@@ -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
- 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)
195
205
  end
196
206
 
197
207
  def delegate(tool, call, context)
198
- arguments = tool.validate_arguments(call.arguments)
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: arguments.fetch("task"), context: context)
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")])
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.7.2"
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.2
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,