turnkit 0.4.2 → 0.6.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +197 -1
  4. data/UPGRADE.md +57 -0
  5. data/lib/generators/turnkit/install/templates/create_turnkit_tables.rb +30 -0
  6. data/lib/generators/turnkit/install/templates/delivery.rb +7 -0
  7. data/lib/generators/turnkit/install/templates/initializer.rb +5 -0
  8. data/lib/generators/turnkit/install/templates/wait.rb +7 -0
  9. data/lib/generators/turnkit/install_generator.rb +2 -0
  10. data/lib/generators/turnkit/upgrade/templates/add_turnkit_durable_orchestration.rb +36 -0
  11. data/lib/generators/turnkit/upgrade_generator.rb +34 -0
  12. data/lib/turnkit/active_record_store.rb +130 -10
  13. data/lib/turnkit/adapters/ruby_llm.rb +14 -0
  14. data/lib/turnkit/agent.rb +26 -20
  15. data/lib/turnkit/authorization.rb +17 -0
  16. data/lib/turnkit/background.rb +281 -0
  17. data/lib/turnkit/budget.rb +4 -3
  18. data/lib/turnkit/conversation.rb +23 -1
  19. data/lib/turnkit/coordination_tools.rb +61 -0
  20. data/lib/turnkit/error.rb +3 -0
  21. data/lib/turnkit/execution_store.rb +30 -0
  22. data/lib/turnkit/id.rb +1 -0
  23. data/lib/turnkit/image_tool.rb +10 -0
  24. data/lib/turnkit/job.rb +21 -0
  25. data/lib/turnkit/memory_store.rb +110 -14
  26. data/lib/turnkit/reconciliation.rb +75 -0
  27. data/lib/turnkit/record.rb +37 -4
  28. data/lib/turnkit/run.rb +15 -0
  29. data/lib/turnkit/skill.rb +5 -4
  30. data/lib/turnkit/specialists.rb +254 -0
  31. data/lib/turnkit/store.rb +42 -2
  32. data/lib/turnkit/sub_agent_tool.rb +23 -7
  33. data/lib/turnkit/system_prompt.rb +7 -7
  34. data/lib/turnkit/tool.rb +11 -0
  35. data/lib/turnkit/tool_runner.rb +109 -29
  36. data/lib/turnkit/turn.rb +188 -57
  37. data/lib/turnkit/version.rb +1 -1
  38. data/lib/turnkit.rb +32 -3
  39. metadata +16 -5
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "net/http"
6
+ require "pathname"
7
+ require "uri"
8
+
9
+ module TurnKit
10
+ # Small, composable factories for common specialist agents. The returned
11
+ # objects are ordinary Agent and Tool instances and may be further configured
12
+ # or subclassed by applications.
13
+ module Specialists
14
+ ORACLE_INSTRUCTIONS = <<~TEXT.strip
15
+ Act as an expert advisor. Inspect the supplied read-only sources before
16
+ drawing conclusions. Complete the requested analysis, distinguish facts
17
+ from recommendations, state material uncertainty, and return the full
18
+ result. Never claim to have changed application state.
19
+ TEXT
20
+
21
+ LIBRARIAN_INSTRUCTIONS = <<~TEXT.strip
22
+ Research the configured external GitHub repository using the repository
23
+ tool. Read the relevant files and history, not merely repository titles.
24
+ Return a complete answer with source URLs for factual claims and identify
25
+ the ref or comparison used. Do not imply that repository state was changed.
26
+ TEXT
27
+
28
+ PAINTER_INSTRUCTIONS = <<~TEXT.strip
29
+ Create or edit the requested image with the image tool. Translate the task
30
+ into a precise visual prompt, pass all relevant reference images and mask,
31
+ and return the generated image artifact. Generation is permitted only when
32
+ the application authorization gate allows the tool call.
33
+ TEXT
34
+
35
+ class ReadOnlyTool < Tool
36
+ recovery :replay_safe
37
+ def self.read_only? = true
38
+ def read_only? = true
39
+ end
40
+
41
+ class ReadFile < ReadOnlyTool
42
+ tool_name "read_file"
43
+ description "Read a UTF-8 text file beneath the configured root."
44
+ parameter :path, :string, required: true, description: "Path relative to the configured root."
45
+
46
+ def initialize(root:, max_bytes: 100_000)
47
+ @root = Pathname(root).expand_path.realpath
48
+ @max_bytes = Integer(max_bytes)
49
+ end
50
+
51
+ def call(path:, context:)
52
+ target = confined_path(path)
53
+ raise ToolError, "not a file: #{path}" unless target.file?
54
+ raise ToolError, "file exceeds #{@max_bytes} bytes" if target.size > @max_bytes
55
+
56
+ { "path" => target.relative_path_from(@root).to_s, "content" => target.binread.force_encoding(Encoding::UTF_8) }
57
+ rescue Errno::ENOENT, Errno::EACCES, ArgumentError => error
58
+ raise ToolError, "cannot read #{path}: #{error.message}"
59
+ end
60
+
61
+ private
62
+
63
+ def confined_path(path)
64
+ candidate = @root.join(path.to_s).realpath
65
+ prefix = @root.to_s + File::SEPARATOR
66
+ raise ToolError, "path is outside configured root" unless candidate == @root || candidate.to_s.start_with?(prefix)
67
+
68
+ candidate
69
+ end
70
+ end
71
+
72
+ class GitHubRepository < ReadOnlyTool
73
+ tool_name "github_repository_read"
74
+ description "Read files, repository metadata, commits, diffs, and issues from one configured GitHub repository."
75
+ parameter :operation, :enum, required: true, enum: %w[repository file history diff issues issue comments]
76
+ parameter :path, :string, description: "Repository-relative file path (file operation)."
77
+ parameter :ref, :string, description: "Branch, tag, or commit for file/history operations."
78
+ parameter :base, :string, description: "Base ref for a diff."
79
+ parameter :head, :string, description: "Head ref for a diff."
80
+ parameter :number, :integer, description: "Issue number."
81
+ parameter :state, :enum, enum: %w[open closed all], default: "open"
82
+ parameter :page, :integer, default: 1
83
+
84
+ API_HOST = "api.github.com"
85
+
86
+ def initialize(repository:, client: nil, token: ENV["GITHUB_TOKEN"])
87
+ @repository = normalize_repository(repository)
88
+ @client = client || method(:http_get)
89
+ @token = token.to_s
90
+ end
91
+
92
+ def call(operation:, path: nil, ref: nil, base: nil, head: nil, number: nil, state: "open", page: 1, context:)
93
+ endpoint, query = endpoint_for(operation, path: path, ref: ref, base: base, head: head,
94
+ number: number, state: state, page: page)
95
+ uri = URI::HTTPS.build(host: API_HOST, path: "/repos/#{@repository}#{endpoint.empty? ? '' : '/' + endpoint}", query: URI.encode_www_form(query))
96
+ response = @client.call(uri: uri, headers: headers)
97
+ status, body = unpack_response(response)
98
+ raise ToolError, "GitHub API returned HTTP #{status}" unless status.between?(200, 299)
99
+
100
+ data = JSON.parse(body)
101
+ if operation == "file" && data.is_a?(Hash) && data["encoding"] == "base64"
102
+ data = data.merge("content" => Base64.decode64(data.fetch("content")))
103
+ end
104
+ { "repository" => @repository, "operation" => operation, "source" => source_url(operation, data, uri), "data" => data }
105
+ rescue JSON::ParserError => error
106
+ raise ToolError, "invalid GitHub API response: #{error.message}"
107
+ end
108
+
109
+ private
110
+
111
+ def normalize_repository(value)
112
+ repository = value.to_s
113
+ raise ArgumentError, "repository must be owner/name" unless repository.match?(%r{\A[A-Za-z0-9][A-Za-z0-9_-]*/[A-Za-z0-9_.-]+\z}) && !%w[. ..].include?(repository.split('/').last)
114
+
115
+ repository
116
+ end
117
+
118
+ def endpoint_for(operation, path:, ref:, base:, head:, number:, state:, page:)
119
+ case operation
120
+ when "repository" then [ "", {} ]
121
+ when "file"
122
+ raise ToolValidationError, "path is required for file" if path.to_s.empty?
123
+ raise ToolValidationError, "invalid repository path" if path.to_s.split("/").include?("..") || path.to_s.start_with?("/")
124
+ [ "contents/#{path.to_s.split('/').map { |part| escape_segment(part) }.join('/')}", compact_query(ref: ref) ]
125
+ when "history" then [ "commits", compact_query(sha: ref, path: path, page: page, per_page: 30) ]
126
+ when "diff"
127
+ raise ToolValidationError, "base and head are required for diff" if base.to_s.empty? || head.to_s.empty?
128
+ [ "compare/#{escape_segment(base)}...#{escape_segment(head)}", {} ]
129
+ when "issues" then [ "issues", compact_query(state: state, page: page, per_page: 30) ]
130
+ when "issue"
131
+ raise ToolValidationError, "number is required for issue" unless number
132
+ [ "issues/#{Integer(number)}", {} ]
133
+ when "comments"
134
+ raise ToolValidationError, "positive issue number is required" unless number && number.positive?
135
+ [ "issues/#{number}/comments", compact_query(page: page, per_page: 30) ]
136
+ else raise ToolValidationError, "unknown operation: #{operation}"
137
+ end
138
+ end
139
+
140
+ def escape_segment(value) = URI.encode_www_form_component(value.to_s).gsub("+", "%20")
141
+ def compact_query(**values) = values.reject { |_key, value| value.nil? || value.to_s.empty? }
142
+
143
+ def headers
144
+ result = { "Accept" => "application/vnd.github+json", "User-Agent" => "TurnKit" }
145
+ result["Authorization"] = "Bearer #{@token}" unless @token.empty?
146
+ result
147
+ end
148
+
149
+ def http_get(uri:, headers:)
150
+ raise ToolError, "unsafe GitHub API endpoint" unless uri.is_a?(URI::HTTPS) && uri.host == API_HOST && uri.port == 443
151
+
152
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 45) { |http| http.get(uri.request_uri, headers) }
153
+ # Redirects are deliberately not followed, preventing credentials from
154
+ # being forwarded to a different endpoint.
155
+ [ response.code.to_i, response.body.to_s ]
156
+ end
157
+
158
+ def unpack_response(response)
159
+ return [ Integer(response[0]), response[1].to_s ] if response.is_a?(Array) && response.length == 2
160
+ return [ Integer(response.code), response.body.to_s ] if response.respond_to?(:code) && response.respond_to?(:body)
161
+
162
+ raise ToolError, "GitHub client must return [status, body] or an HTTP response"
163
+ end
164
+
165
+ def source_url(operation, data, uri)
166
+ return data["html_url"] if data.is_a?(Hash) && data["html_url"].to_s.start_with?("https://github.com/")
167
+ return data.first["html_url"] if data.is_a?(Array) && data.first.is_a?(Hash) && data.first["html_url"].to_s.start_with?("https://github.com/")
168
+
169
+ uri.to_s
170
+ end
171
+ end
172
+
173
+ class PaintImage < ImageTool
174
+ tool_name "paint_image"
175
+ description "Generate or edit an image after application authorization."
176
+ parameter :prompt, :string, required: true
177
+ parameter :reference_images, :array, default: [], items: :string
178
+ parameter :mask, :string
179
+ terminal! { |result| JSON.generate(result) }
180
+
181
+ def initialize(model:, authorization:, provider: nil, size: nil, params: {}, max_reference_images: nil)
182
+ raise ArgumentError, "authorization must be callable" unless authorization.respond_to?(:call)
183
+ @image_model, @authorization, @provider, @size = model, authorization, provider, size
184
+ @params, @max_reference_images = params, max_reference_images
185
+ end
186
+
187
+ def call(prompt:, reference_images: [], mask: nil, context:)
188
+ references = Array(reference_images)
189
+ if @max_reference_images && references.length > Integer(@max_reference_images)
190
+ raise ToolValidationError, "reference_images exceeds configured limit of #{@max_reference_images}"
191
+ end
192
+ request = { prompt: prompt, model: @image_model, provider: @provider, size: @size,
193
+ input_images: references, mask: mask, params: @params }
194
+ raise ToolError, "image generation was not authorized" unless @authorization.call(request.dup, context: context) == true
195
+
196
+ image = context.turn.paint(request.delete(:prompt), **request, metadata: { "specialist" => "painter" })
197
+ message = context.turn.conversation.messages.reverse.find(&:image?)
198
+ { "conversation_id" => context.turn.conversation.id, "image_message_id" => message.id,
199
+ "image" => image.to_h.reject { |key, _| key == "data" } }
200
+ end
201
+ end
202
+
203
+ module_function
204
+
205
+ def oracle(model:, tools: nil, root: Dir.pwd, client: nil, instructions: nil, **agent_options)
206
+ require_model!(model)
207
+ configured = tools.nil? ? [ ReadFile.new(root: root) ] : Array(tools)
208
+ validate_read_only!(configured + skill_tools(agent_options))
209
+ raise ArgumentError, "read-only specialists cannot delegate to unchecked subagents" if Array(agent_options[:sub_agents]).any?
210
+ Agent.new(name: "oracle", description: "Read-only expert analysis and advice.", model: model,
211
+ tools: configured, client: client, instructions: combine(ORACLE_INSTRUCTIONS, instructions), **agent_options, inherit_globals: false)
212
+ end
213
+
214
+ def librarian(repository:, model:, client: nil, github_client: nil, token: ENV["GITHUB_TOKEN"], tools: [], instructions: nil, **agent_options)
215
+ require_model!(model)
216
+ repository_tool = GitHubRepository.new(repository: repository, client: github_client, token: token)
217
+ validate_read_only!(Array(tools) + skill_tools(agent_options))
218
+ raise ArgumentError, "read-only specialists cannot delegate to unchecked subagents" if Array(agent_options[:sub_agents]).any?
219
+ Agent.new(name: "librarian", description: "Source-backed research in an external GitHub repository.", model: model,
220
+ tools: [ repository_tool, *Array(tools) ], client: client,
221
+ instructions: combine(LIBRARIAN_INSTRUCTIONS, instructions), **agent_options, inherit_globals: false)
222
+ end
223
+
224
+ def painter(model:, image_model:, authorization:, client: nil, tools: [], instructions: nil, provider: nil, size: nil,
225
+ params: {}, max_reference_images: nil, **agent_options)
226
+ require_model!(model)
227
+ require_model!(image_model)
228
+ image_tool = PaintImage.new(model: image_model, authorization: authorization, provider: provider, size: size,
229
+ params: params, max_reference_images: max_reference_images)
230
+ Agent.new(name: "painter", description: "Authorized image generation and editing.", model: model,
231
+ tools: [ image_tool, *Array(tools) ], client: client,
232
+ instructions: combine(PAINTER_INSTRUCTIONS, instructions), **agent_options, inherit_globals: false)
233
+ end
234
+
235
+ def skill_tools(options)
236
+ (Array(options[:skills]) + Array(options[:available_skills])).flat_map(&:tools)
237
+ end
238
+ private_class_method :skill_tools
239
+
240
+ def validate_read_only!(tools)
241
+ invalid = tools.reject { |tool| tool.respond_to?(:read_only?) && tool.read_only? == true }
242
+ raise ArgumentError, "read-only specialist tools must explicitly report read_only? == true" if invalid.any?
243
+ end
244
+ private_class_method :validate_read_only!
245
+
246
+ def require_model!(model)
247
+ raise ArgumentError, "model is required" if model.nil? || model.to_s.empty?
248
+ end
249
+ private_class_method :require_model!
250
+
251
+ def combine(base, extra) = [ base, extra.to_s.strip ].reject(&:empty?).join("\n\n")
252
+ private_class_method :combine
253
+ end
254
+ end
data/lib/turnkit/store.rb CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  module TurnKit
4
4
  class Store
5
+ def atomic(_conversation_id, &) = raise(NotImplementedError)
6
+ def atomic_graph(&) = atomic(nil, &)
7
+
5
8
  def create_conversation(_attributes) = raise(NotImplementedError)
6
9
  def load_conversation(_id) = raise(NotImplementedError)
7
10
 
@@ -17,12 +20,49 @@ module TurnKit
17
20
  # is not in `from`), so concurrent workers cannot both claim a turn.
18
21
  def claim_turn(_id, from: "pending", to: "running", **_attributes) = raise(NotImplementedError)
19
22
  def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil) = raise(NotImplementedError)
23
+ # Public inventory. Maintenance uses the explicitly bounded active scope.
24
+ def list_submitted_turns(limit: nil) = raise(NotImplementedError)
25
+ def list_actionable_turns(limit:) = raise(NotImplementedError)
26
+ def list_stale_inline_turns(before:, limit:) = raise(NotImplementedError)
27
+
28
+ # Stores may optimize these continuation queries without loading history.
29
+ def busy_conversation?(id, include_pending: true)
30
+ list_turns(conversation_id: id).any? { |row| %w[running waiting].include?(row["status"]) || (include_pending && row["submitted_at"] && row["status"] == "pending") }
31
+ end
32
+
33
+ def next_delivery_trigger(id)
34
+ consumed = list_turns(conversation_id: id).reject { |row| row["status"] == "pending" && !row["submitted_at"] }.map { |row| row["context_message_sequence"] }.max.to_i
35
+ list_messages(id).select { |row| row.dig("metadata", "delivery_id") && row["sequence"] > consumed }.last
36
+ end
37
+
38
+ def create_delivery(_attributes) = raise(NotImplementedError)
39
+ def load_delivery(_id) = raise(NotImplementedError)
40
+ def update_delivery(_id, _attributes) = raise(NotImplementedError)
41
+ def list_deliveries(source_conversation_id: nil, destination_conversation_id: nil, pending: false, limit: nil) = raise(NotImplementedError)
42
+
43
+ def create_wait(turn_id:, target_turn_id:) = raise(NotImplementedError)
44
+ def list_waits(turn_id: nil, target_turn_id: nil) = raise(NotImplementedError)
20
45
 
21
46
  def create_tool_execution(_attributes) = raise(NotImplementedError)
22
47
  def load_tool_execution(_id) = raise(NotImplementedError)
23
- def update_tool_execution(_id, _attributes) = raise(NotImplementedError)
48
+ # claim_tool_execution mirrors claim_turn: an atomic compare-and-set on
49
+ # status, so a tool result recorded by a live worker and a reconciler
50
+ # marking the execution interrupted cannot overwrite each other.
51
+ def claim_tool_execution(_id, from: "running", to: "completed", **_attributes) = raise(NotImplementedError)
24
52
  def list_tool_executions(turn_id:) = raise(NotImplementedError)
25
53
 
26
- def find_stale_turns(before:) = []
54
+ # Inline abandoned work is fenced and left stale for application-directed
55
+ # continuation. Submitted work is resumed by Background.reconcile instead.
56
+ def reconcile_stale_turns(before:)
57
+ list_stale_inline_turns(before: before, limit: TurnKit.maintenance_batch_size).filter_map do |record|
58
+ atomic(Background.root_conversation(self, record)) do
59
+ current = load_turn(record.fetch("id"))
60
+ anchor = current["heartbeat_at"] || current["started_at"] || current["created_at"]
61
+ next if current["submitted_at"] || !%w[pending running].include?(current["status"]) || anchor >= before
62
+
63
+ update_turn(current.fetch("id"), status: "stale", claim_token: nil, completed_at: Clock.now)
64
+ end
65
+ end
66
+ end
27
67
  end
28
68
  end
@@ -17,17 +17,20 @@ module TurnKit
17
17
  end
18
18
  end
19
19
 
20
- def call(task:, context:)
21
- sub_agent = self.class.agent
20
+ def self.build_child(task:, context:)
21
+ sub_agent = agent
22
22
  parent_turn = context.turn
23
23
  lineage = {
24
24
  "parent_conversation_id" => parent_turn.conversation.id,
25
25
  "parent_turn_id" => parent_turn.id,
26
- "parent_tool_execution_id" => context.execution.id
26
+ "parent_tool_execution_id" => context.execution.id,
27
+ "principal" => context.principal
27
28
  }
28
- conversation = sub_agent.conversation(metadata: lineage)
29
+ 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)
29
32
  trigger = conversation.say(task, metadata: lineage)
30
- child = conversation.run!(
33
+ conversation.build_turn(
31
34
  trigger_message_id: trigger.id,
32
35
  budget: parent_turn.budget,
33
36
  parent_turn: parent_turn,
@@ -35,10 +38,23 @@ module TurnKit
35
38
  depth: parent_turn.depth + 1,
36
39
  model: sub_agent.effective_model,
37
40
  agent: sub_agent,
41
+ principal: context.principal,
38
42
  on_event: parent_turn.agent.effective_on_event
39
43
  )
40
- error = child.store.load_turn(child.id)["error"] if child.failed?
41
- { "conversation_id" => conversation.id, "turn_id" => child.id, "status" => child.status, "result" => child.output_text, "output_data" => child.output_data, "error" => error }.compact
44
+ end
45
+
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:)
53
+ 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)
56
+ child.run!
57
+ SubAgentTool.result(child.store.load_turn(child.id))
42
58
  end
43
59
  end
44
60
  end
@@ -7,7 +7,7 @@ module TurnKit
7
7
  PROMPT_MODES = %i[full minimal task none].freeze
8
8
  MODE_SECTIONS = {
9
9
  full: DEFAULT_SECTIONS,
10
- minimal: %i[agent sub_agent instructions behavior tools environment],
10
+ minimal: %i[agent sub_agent instructions behavior loaded_skills available_skills tools environment],
11
11
  task: DEFAULT_SECTIONS,
12
12
  none: []
13
13
  }.freeze
@@ -181,7 +181,7 @@ module TurnKit
181
181
  end
182
182
 
183
183
  def tools_section
184
- tools = agent.effective_tools
184
+ tools = agent.effective_tools(turn: turn)
185
185
 
186
186
  if tools.empty?
187
187
  tagged("tools_available", "(none)\n\nNo tools are available for this turn.")
@@ -197,9 +197,7 @@ module TurnKit
197
197
  end
198
198
 
199
199
  def subject_section
200
- return nil unless conversation.subject&.respond_to?(:to_prompt)
201
-
202
- value = conversation.subject.to_prompt.to_s.strip
200
+ value = conversation.subject_prompt.strip
203
201
  return nil if value.empty?
204
202
 
205
203
  untrusted_section(
@@ -211,9 +209,11 @@ module TurnKit
211
209
  end
212
210
 
213
211
  def live_context_section
214
- contributions = Array(TurnKit.context_contributors).filter_map do |contributor|
212
+ contributors = agent.context_contributors
213
+ contributions = contributors.filter_map do |contributor|
215
214
  normalize_context_contribution(contributor.call(prompt_build_context))
216
215
  end
216
+ contributions.unshift(normalize_context_contribution(name: "run_context", content: JSON.generate(turn.context), trusted: false)) unless turn.context.empty?
217
217
  return nil if contributions.empty?
218
218
 
219
219
  body = contributions.map do |contribution|
@@ -280,7 +280,7 @@ module TurnKit
280
280
  "stable_chars" => stable.length,
281
281
  "dynamic_chars" => dynamic.length,
282
282
  "sections" => sections.map(&:to_s),
283
- "tool_count" => agent.effective_tools.length
283
+ "tool_count" => agent.effective_tools(turn: turn).length
284
284
  }
285
285
  end
286
286
 
data/lib/turnkit/tool.rb CHANGED
@@ -54,6 +54,17 @@ module TurnKit
54
54
  @ends_turn || false
55
55
  end
56
56
 
57
+ # :unknown is the safe default for external effects. :replay_safe means
58
+ # the application/tool honors ToolContext#idempotency_key on retries.
59
+ def recovery(value = nil)
60
+ if value
61
+ value = value.to_sym
62
+ raise ArgumentError, "recovery must be :unknown or :replay_safe" unless %i[unknown replay_safe].include?(value)
63
+ @recovery = value
64
+ end
65
+ @recovery || (superclass.respond_to?(:recovery) ? superclass.recovery : :unknown)
66
+ end
67
+
57
68
  def completion_message(result)
58
69
  case @completion_message
59
70
  when nil
@@ -7,14 +7,22 @@ module TurnKit
7
7
  end
8
8
 
9
9
  def dispatch(tool_calls)
10
+ waiting = false
10
11
  tool_calls.each_with_index do |tool_call, index|
11
- execution = run(tool_call)
12
+ # Fan out a contiguous group of subagents, but never reorder ordinary
13
+ # tools across it or execute past a terminal tool.
14
+ return :waiting if waiting && !subagent?(tool_for(tool_call.name))
15
+ execution = run(tool_call, defer_result: waiting)
16
+ if execution == :waiting
17
+ waiting = true
18
+ next
19
+ end
12
20
  if execution.completed? && tool_for(tool_call.name)&.ends_turn?
13
21
  skip_remaining(tool_calls.drop(index + 1), terminal: tool_call)
14
22
  return execution
15
23
  end
16
24
  end
17
- nil
25
+ waiting ? :waiting : nil
18
26
  end
19
27
 
20
28
  def completion_message(execution)
@@ -25,11 +33,30 @@ module TurnKit
25
33
  private
26
34
  attr_reader :turn
27
35
 
28
- def run(tool_call)
29
- execution = ToolExecution.new(create_execution(tool_call))
30
- heartbeat!
31
-
36
+ def run(tool_call, defer_result: false)
37
+ @defer_result = defer_result
32
38
  tool = tool_for(tool_call.name)
39
+ existing = turn.store.list_tool_executions(turn_id: turn.id).find { |row| row["tool_call_id"] == tool_call.id }
40
+ if existing && !%w[pending running].include?(existing["status"])
41
+ execution = ToolExecution.new(existing)
42
+ append_result_once(execution, tool_call, execution.result || execution.error, error: !execution.completed? && !execution.cancelled?)
43
+ return execution
44
+ end
45
+
46
+ denied = nil
47
+ execution = turn.store.atomic do
48
+ execution = ToolExecution.new(existing || create_execution(tool_call))
49
+ unless existing
50
+ begin
51
+ turn.execution_budget(excluding: execution.id).count_tool_execution!(tool_call.name)
52
+ rescue BudgetError => error
53
+ denied = error
54
+ finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
55
+ end
56
+ end
57
+ execution
58
+ end
59
+ raise denied if denied
33
60
 
34
61
  unless tool
35
62
  return finish_error(execution, tool_call, "unknown tool: #{tool_call.name}")
@@ -39,20 +66,33 @@ module TurnKit
39
66
  return finish_error(execution, tool_call, tool_call.arguments_error)
40
67
  end
41
68
 
42
- begin
43
- turn.budget.count_tool_execution!(tool_call.name)
44
- rescue BudgetError => error
45
- finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
46
- raise
69
+ if execution.status == "pending" && !subagent?(tool) && ![WaitTool, LaunchAgentTool, SendMessageTool].include?(tool)
70
+ claimed = turn.store.claim_tool_execution(execution.id, from: "pending", to: "running", started_at: Clock.now)
71
+ raise LostClaim, "tool execution claim was revoked" unless claimed
72
+ execution = ToolExecution.new(claimed)
47
73
  end
48
74
 
49
75
  context = ToolContext.new(turn: turn, execution: execution)
50
76
  payload = begin
51
- normalize_payload(call_tool(tool, tool_call.arguments, context: context))
77
+ Authorization.authorize!(:tool, principal: context.principal, turn: turn, tool: tool, arguments: tool_call.arguments)
78
+ # Observe cancellation/reconciliation immediately before crossing the
79
+ # external-effect boundary. Calls already sent cannot be recalled.
80
+ turn.store.atomic { true }
81
+ if turn.background? && subagent?(tool)
82
+ return delegate(tool, tool_call, context)
83
+ end
84
+ value = call_tool(tool, tool_call.arguments, context: context)
85
+ return :waiting if value == :waiting && tool == WaitTool
86
+ normalize_payload(value)
87
+ rescue LostClaim
88
+ raise
52
89
  rescue BudgetError => error
53
90
  finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
54
91
  raise
92
+ rescue AuthorizationError => error
93
+ return finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "authorization_denied" => true })
55
94
  rescue StandardError => error
95
+ raise if turn.background? && !error.is_a?(ToolError)
56
96
  return finish_error(execution, tool_call, error.message, details: { "class" => error.class.name })
57
97
  end
58
98
  finish_success(execution, tool_call, payload)
@@ -63,7 +103,7 @@ module TurnKit
63
103
  "turn_id" => turn.id,
64
104
  "tool_call_id" => tool_call.id,
65
105
  "tool_name" => tool_call.name,
66
- "status" => "running",
106
+ "status" => turn.background? && (subagent?(tool_for(tool_call.name)) || [WaitTool, LaunchAgentTool, SendMessageTool].include?(tool_for(tool_call.name))) ? "pending" : "running",
67
107
  "arguments" => tool_call.arguments,
68
108
  "started_at" => Clock.now
69
109
  )
@@ -71,9 +111,12 @@ module TurnKit
71
111
 
72
112
  def finish_success(execution, tool_call, payload)
73
113
  json = payload.to_json
74
- attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
75
- append_result(execution, tool_call, payload, json: json, error: false)
76
- heartbeat!
114
+ attrs = turn.store.atomic do
115
+ row = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "completed", result: payload, completed_at: Clock.now)
116
+ append_result_once(execution, tool_call, payload) if row
117
+ row
118
+ end
119
+ return superseded_execution(execution) unless attrs
77
120
  turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: json.length)
78
121
  ToolExecution.new(attrs)
79
122
  end
@@ -81,18 +124,30 @@ module TurnKit
81
124
  def finish_error(execution, tool_call, message, details: nil)
82
125
  error = { "message" => message.to_s, "details" => details }.compact
83
126
  json = error.to_json
84
- attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
85
- append_result(execution, tool_call, error, json: json, error: true)
86
- heartbeat!
127
+ attrs = turn.store.atomic do
128
+ row = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "failed", error: error, completed_at: Clock.now)
129
+ append_result_once(execution, tool_call, error, error: true) if row
130
+ row
131
+ end
132
+ return superseded_execution(execution) unless attrs
87
133
  turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: json.length)
88
134
  ToolExecution.new(attrs)
89
135
  end
90
136
 
91
- def append_result(execution, tool_call, payload, json: payload.to_json, error: false)
137
+ # The execution was reconciled (interrupted) while the tool ran; a
138
+ # synthetic result message already exists, so the late result is dropped.
139
+ def superseded_execution(execution)
140
+ ToolExecution.new(turn.store.load_tool_execution(execution.id))
141
+ end
142
+
143
+ def append_result_once(execution, tool_call, payload, error: false)
144
+ return if @defer_result
145
+ return if turn.store.list_messages(turn.conversation.id).any? { |row| row["tool_execution_id"] == execution.id }
146
+
92
147
  message = turn.conversation.append_message(
93
148
  role: "tool",
94
149
  kind: "tool_result",
95
- content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => json, "error" => error } ],
150
+ content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => payload.to_json, "error" => error } ],
96
151
  turn_id: turn.id,
97
152
  tool_execution_id: execution.id,
98
153
  metadata: { "tool_name" => tool_call.name }
@@ -102,20 +157,45 @@ module TurnKit
102
157
 
103
158
  def skip_remaining(calls, terminal:)
104
159
  calls.each do |call|
105
- payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
106
- execution = ToolExecution.new(create_execution(call))
107
- attrs = turn.store.update_tool_execution(execution.id, "status" => "cancelled", "result" => payload, "completed_at" => Clock.now)
108
- append_result(ToolExecution.new(attrs), call, payload)
109
- turn.emit("tool_call.skipped", id: call.id, name: call.name)
160
+ turn.store.atomic do
161
+ next if turn.store.list_tool_executions(turn_id: turn.id).any? { |row| row["tool_call_id"] == call.id }
162
+ payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
163
+ execution = ToolExecution.new(create_execution(call))
164
+ attrs = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "cancelled", result: payload, completed_at: Clock.now)
165
+ append_result_once(ToolExecution.new(attrs), call, payload)
166
+ turn.emit("tool_call.skipped", id: call.id, name: call.name)
167
+ end
110
168
  end
111
169
  end
112
170
 
113
- def heartbeat!
114
- turn.send(:heartbeat!)
171
+ def subagent?(tool)
172
+ tool.is_a?(Class) && tool < SubAgentTool
173
+ end
174
+
175
+ def delegate(tool, call, context)
176
+ arguments = tool.validate_arguments(call.arguments)
177
+ Authorization.authorize!(:launch_agent, principal: context.principal, turn: turn, agent: tool.agent, arguments: arguments)
178
+ TurnKit.resolve_agent(tool.agent.name)
179
+ child = turn.store.atomic_graph do
180
+ turn.store.atomic(Background.root_conversation(turn.store, turn.store.load_turn(turn.id))) do
181
+ row = turn.store.list_turns(root_turn_id: turn.root_turn_id).find { |candidate| candidate["parent_tool_execution_id"] == context.execution.id }
182
+ unless row
183
+ built = tool.build_child(task: arguments.fetch("task"), context: context)
184
+ row = turn.store.update_turn(built.id, submitted_at: Clock.now)
185
+ end
186
+ Background.wait(turn, [row.fetch("id")])
187
+ row
188
+ end
189
+ end
190
+ unless Background::TERMINAL.include?(child["status"])
191
+ Background.enqueue(child.fetch("id")) if child["status"] == "pending"
192
+ return :waiting
193
+ end
194
+ finish_success(context.execution, call, SubAgentTool.result(child))
115
195
  end
116
196
 
117
197
  def tool_for(name)
118
- turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
198
+ turn.agent.effective_tools(turn: turn).find { |tool| tool.tool_name == name.to_s }
119
199
  end
120
200
 
121
201
  def call_tool(tool, arguments, context:)