turnkit 0.5.0 → 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 +23 -0
- data/README.md +187 -5
- 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 +124 -14
- 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 +106 -15
- data/lib/turnkit/reconciliation.rb +13 -10
- data/lib/turnkit/record.rb +36 -3
- 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 +38 -9
- 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 +101 -45
- data/lib/turnkit/turn.rb +188 -57
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +30 -0
- metadata +15 -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,6 +20,28 @@ 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)
|
|
@@ -26,14 +51,18 @@ module TurnKit
|
|
|
26
51
|
def claim_tool_execution(_id, from: "running", to: "completed", **_attributes) = raise(NotImplementedError)
|
|
27
52
|
def list_tool_executions(turn_id:) = raise(NotImplementedError)
|
|
28
53
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
38
67
|
end
|
|
39
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
|