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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +197 -1
- data/UPGRADE.md +57 -0
- data/lib/generators/turnkit/install/templates/create_turnkit_tables.rb +30 -0
- data/lib/generators/turnkit/install/templates/delivery.rb +7 -0
- data/lib/generators/turnkit/install/templates/initializer.rb +5 -0
- data/lib/generators/turnkit/install/templates/wait.rb +7 -0
- data/lib/generators/turnkit/install_generator.rb +2 -0
- data/lib/generators/turnkit/upgrade/templates/add_turnkit_durable_orchestration.rb +36 -0
- data/lib/generators/turnkit/upgrade_generator.rb +34 -0
- data/lib/turnkit/active_record_store.rb +130 -10
- data/lib/turnkit/adapters/ruby_llm.rb +14 -0
- data/lib/turnkit/agent.rb +26 -20
- data/lib/turnkit/authorization.rb +17 -0
- data/lib/turnkit/background.rb +281 -0
- data/lib/turnkit/budget.rb +4 -3
- data/lib/turnkit/conversation.rb +23 -1
- data/lib/turnkit/coordination_tools.rb +61 -0
- data/lib/turnkit/error.rb +3 -0
- data/lib/turnkit/execution_store.rb +30 -0
- data/lib/turnkit/id.rb +1 -0
- data/lib/turnkit/image_tool.rb +10 -0
- data/lib/turnkit/job.rb +21 -0
- data/lib/turnkit/memory_store.rb +110 -14
- data/lib/turnkit/reconciliation.rb +75 -0
- data/lib/turnkit/record.rb +37 -4
- data/lib/turnkit/run.rb +15 -0
- data/lib/turnkit/skill.rb +5 -4
- data/lib/turnkit/specialists.rb +254 -0
- data/lib/turnkit/store.rb +42 -2
- data/lib/turnkit/sub_agent_tool.rb +23 -7
- data/lib/turnkit/system_prompt.rb +7 -7
- data/lib/turnkit/tool.rb +11 -0
- data/lib/turnkit/tool_runner.rb +109 -29
- data/lib/turnkit/turn.rb +188 -57
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +32 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
|
21
|
-
sub_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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/turnkit/tool_runner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
turn.
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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.
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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" =>
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
114
|
-
|
|
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:)
|