turnkit 0.4.0 → 0.4.2
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 +25 -0
- data/README.md +170 -49
- data/UPGRADE.md +29 -0
- data/UPGRADE_TO_0_4_2.md +425 -0
- data/lib/{turnkit/generators → generators}/turnkit/install/templates/initializer.rb +7 -6
- data/lib/turnkit/{stores/active_record_store.rb → active_record_store.rb} +18 -4
- data/lib/turnkit/adapters/codex.rb +8 -11
- data/lib/turnkit/adapters/ruby_llm.rb +68 -44
- data/lib/turnkit/agent.rb +43 -10
- data/lib/turnkit/budget.rb +1 -1
- data/lib/turnkit/client.rb +9 -1
- data/lib/turnkit/compaction.rb +46 -46
- data/lib/turnkit/cost.rb +29 -31
- data/lib/turnkit/image_result.rb +1 -0
- data/lib/turnkit/image_tool.rb +2 -2
- data/lib/turnkit/media_analysis_result.rb +46 -0
- data/lib/turnkit/media_input.rb +208 -0
- data/lib/turnkit/message.rb +5 -1
- data/lib/turnkit/message_projection.rb +11 -0
- data/lib/turnkit/model_request.rb +4 -2
- data/lib/turnkit/output_policy.rb +10 -15
- data/lib/turnkit/result.rb +12 -0
- data/lib/turnkit/run.rb +0 -4
- data/lib/turnkit/store.rb +4 -6
- data/lib/turnkit/sub_agent_tool.rb +9 -14
- data/lib/turnkit/system_prompt.rb +32 -96
- data/lib/turnkit/tool.rb +5 -16
- data/lib/turnkit/turn.rb +113 -58
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit/view_media_tool.rb +30 -0
- data/lib/turnkit.rb +9 -18
- metadata +18 -30
- data/lib/turnkit/prompt_contribution.rb +0 -13
- data/lib/turnkit/rails/railtie.rb +0 -9
- data/lib/turnkit/workflow.rb +0 -58
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/conversation.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/create_turnkit_tables.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/message.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/tool_execution.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install/templates/turn.rb +0 -0
- /data/lib/{turnkit/generators → generators}/turnkit/install_generator.rb +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
class MediaAnalysisResult
|
|
5
|
+
attr_reader :text, :data, :model, :provider, :usage, :params, :media, :metadata, :error
|
|
6
|
+
|
|
7
|
+
def self.from_h(value)
|
|
8
|
+
new(**value.transform_keys(&:to_sym))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(text: "", data: nil, model: nil, provider: nil, usage: Usage.new, params: {}, media: {}, metadata: {}, error: nil, **)
|
|
12
|
+
@text = text.to_s
|
|
13
|
+
@data = data
|
|
14
|
+
@model = model
|
|
15
|
+
@provider = provider
|
|
16
|
+
@usage = usage.is_a?(Usage) ? usage : Usage.from_h(usage || {})
|
|
17
|
+
@params = params || {}
|
|
18
|
+
@media = media || {}
|
|
19
|
+
@metadata = metadata || {}
|
|
20
|
+
@error = error
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def data?
|
|
24
|
+
!data.nil?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cost
|
|
28
|
+
Cost.from_usage(usage, model: model)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
{
|
|
33
|
+
"text" => text,
|
|
34
|
+
"data" => data,
|
|
35
|
+
"model" => model,
|
|
36
|
+
"provider" => provider,
|
|
37
|
+
"usage" => usage.to_h,
|
|
38
|
+
"cost" => cost.to_h,
|
|
39
|
+
"params" => params,
|
|
40
|
+
"media" => media,
|
|
41
|
+
"metadata" => metadata,
|
|
42
|
+
"error" => error
|
|
43
|
+
}.compact
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module TurnKit
|
|
8
|
+
class MediaInput
|
|
9
|
+
SUPPORTED_MIME_TYPES = %w[image/png image/jpeg image/webp image/gif application/pdf].freeze
|
|
10
|
+
EXTENSION_MIME_TYPES = {
|
|
11
|
+
".png" => "image/png",
|
|
12
|
+
".jpg" => "image/jpeg",
|
|
13
|
+
".jpeg" => "image/jpeg",
|
|
14
|
+
".webp" => "image/webp",
|
|
15
|
+
".gif" => "image/gif",
|
|
16
|
+
".pdf" => "application/pdf",
|
|
17
|
+
".mp3" => "audio/mpeg",
|
|
18
|
+
".wav" => "audio/wav",
|
|
19
|
+
".m4a" => "audio/mp4",
|
|
20
|
+
".mp4" => "video/mp4",
|
|
21
|
+
".mov" => "video/quicktime",
|
|
22
|
+
".webm" => "video/webm"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :source, :mime_type, :filename, :metadata, :source_type
|
|
26
|
+
|
|
27
|
+
def self.wrap(value, **options)
|
|
28
|
+
value.is_a?(self) && options.empty? ? value : new(value, **options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.bytes(data, mime_type:, filename: nil, metadata: {})
|
|
32
|
+
new(data, source_type: :bytes, mime_type: mime_type, filename: filename, metadata: metadata)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(source, mime_type: nil, filename: nil, metadata: {}, source_type: nil)
|
|
36
|
+
@source = source
|
|
37
|
+
@source_type = (source_type || infer_source_type).to_s
|
|
38
|
+
@filename = filename || infer_filename
|
|
39
|
+
@mime_type = mime_type || infer_mime_type
|
|
40
|
+
@metadata = metadata || {}
|
|
41
|
+
|
|
42
|
+
validate!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def kind
|
|
46
|
+
return "image" if mime_type&.start_with?("image/")
|
|
47
|
+
return "audio" if mime_type&.start_with?("audio/")
|
|
48
|
+
return "video" if mime_type&.start_with?("video/")
|
|
49
|
+
return "pdf" if mime_type == "application/pdf"
|
|
50
|
+
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def byte_size
|
|
55
|
+
case source_type
|
|
56
|
+
when "path"
|
|
57
|
+
File.size(source.to_s) if File.file?(source.to_s)
|
|
58
|
+
when "bytes"
|
|
59
|
+
source.bytesize
|
|
60
|
+
when "io"
|
|
61
|
+
source.size if source.respond_to?(:size)
|
|
62
|
+
when "active_storage"
|
|
63
|
+
active_storage_byte_size
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def url
|
|
68
|
+
source.to_s if source_type == "url"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def path
|
|
72
|
+
source.to_s if source_type == "path"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def attachment_source
|
|
76
|
+
case source_type
|
|
77
|
+
when "bytes"
|
|
78
|
+
StringIO.new(source)
|
|
79
|
+
else
|
|
80
|
+
source
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_h
|
|
85
|
+
{
|
|
86
|
+
"kind" => kind,
|
|
87
|
+
"mime_type" => mime_type,
|
|
88
|
+
"filename" => filename,
|
|
89
|
+
"byte_size" => byte_size,
|
|
90
|
+
"url" => url,
|
|
91
|
+
"path" => path,
|
|
92
|
+
"metadata" => metadata
|
|
93
|
+
}.compact
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
def infer_source_type
|
|
98
|
+
return :url if source.to_s.match?(%r{\Ahttps?://})
|
|
99
|
+
return :active_storage if active_storage?
|
|
100
|
+
return :path if source.is_a?(Pathname) || (source.is_a?(String) && File.exist?(source))
|
|
101
|
+
return :io if source.respond_to?(:read)
|
|
102
|
+
return :bytes if source.is_a?(String)
|
|
103
|
+
|
|
104
|
+
raise ArgumentError, "unsupported media input: #{source.class}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def infer_filename
|
|
108
|
+
case source_type
|
|
109
|
+
when "url"
|
|
110
|
+
basename = File.basename(URI(source.to_s).path).to_s
|
|
111
|
+
basename.empty? ? nil : basename
|
|
112
|
+
when "path"
|
|
113
|
+
File.basename(source.to_s)
|
|
114
|
+
when "io"
|
|
115
|
+
source.respond_to?(:path) ? File.basename(source.path.to_s) : nil
|
|
116
|
+
when "active_storage"
|
|
117
|
+
active_storage_filename
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def infer_mime_type
|
|
122
|
+
active_storage_content_type || mime_from_filename || mime_from_marcel
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def mime_from_filename
|
|
126
|
+
EXTENSION_MIME_TYPES[File.extname(filename.to_s).downcase]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def mime_from_marcel
|
|
130
|
+
require "marcel"
|
|
131
|
+
|
|
132
|
+
Marcel::MimeType.for(marcel_io, name: filename)
|
|
133
|
+
rescue LoadError
|
|
134
|
+
nil
|
|
135
|
+
ensure
|
|
136
|
+
rewind_source
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def marcel_io
|
|
140
|
+
case source_type
|
|
141
|
+
when "path"
|
|
142
|
+
Pathname.new(source.to_s)
|
|
143
|
+
when "bytes"
|
|
144
|
+
StringIO.new(source)
|
|
145
|
+
when "io"
|
|
146
|
+
source
|
|
147
|
+
else
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate!
|
|
153
|
+
return if mime_type.nil?
|
|
154
|
+
return if SUPPORTED_MIME_TYPES.include?(mime_type)
|
|
155
|
+
return if mime_type.start_with?("audio/", "video/")
|
|
156
|
+
|
|
157
|
+
raise ArgumentError, "unsupported media type: #{mime_type}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def active_storage?
|
|
161
|
+
return false unless defined?(ActiveStorage)
|
|
162
|
+
|
|
163
|
+
(defined?(ActiveStorage::Blob) && source.is_a?(ActiveStorage::Blob)) ||
|
|
164
|
+
(defined?(ActiveStorage::Attached::One) && source.is_a?(ActiveStorage::Attached::One)) ||
|
|
165
|
+
(defined?(ActiveStorage::Attached::Many) && source.is_a?(ActiveStorage::Attached::Many))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def active_storage_filename
|
|
169
|
+
if defined?(ActiveStorage::Blob) && source.is_a?(ActiveStorage::Blob)
|
|
170
|
+
source.filename.to_s
|
|
171
|
+
elsif source.respond_to?(:filename)
|
|
172
|
+
source.filename.to_s
|
|
173
|
+
elsif source.respond_to?(:blob)
|
|
174
|
+
source.blob&.filename&.to_s
|
|
175
|
+
elsif source.respond_to?(:blobs)
|
|
176
|
+
source.blobs.first&.filename&.to_s
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def active_storage_content_type
|
|
181
|
+
if defined?(ActiveStorage::Blob) && source.is_a?(ActiveStorage::Blob)
|
|
182
|
+
source.content_type
|
|
183
|
+
elsif source.respond_to?(:content_type)
|
|
184
|
+
source.content_type
|
|
185
|
+
elsif source.respond_to?(:blob)
|
|
186
|
+
source.blob&.content_type
|
|
187
|
+
elsif source.respond_to?(:blobs)
|
|
188
|
+
source.blobs.first&.content_type
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def active_storage_byte_size
|
|
193
|
+
if defined?(ActiveStorage::Blob) && source.is_a?(ActiveStorage::Blob)
|
|
194
|
+
source.byte_size
|
|
195
|
+
elsif source.respond_to?(:byte_size)
|
|
196
|
+
source.byte_size
|
|
197
|
+
elsif source.respond_to?(:blob)
|
|
198
|
+
source.blob&.byte_size
|
|
199
|
+
elsif source.respond_to?(:blobs)
|
|
200
|
+
source.blobs.first&.byte_size
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def rewind_source
|
|
205
|
+
source.rewind if source_type == "io" && source.respond_to?(:rewind)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
data/lib/turnkit/message.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Message
|
|
5
5
|
ROLES = %w[user assistant tool].freeze
|
|
6
|
-
KINDS = %w[text tool_call tool_result context_summary image].freeze
|
|
6
|
+
KINDS = %w[text tool_call tool_result context_summary image media_analysis].freeze
|
|
7
7
|
|
|
8
8
|
attr_reader :id, :conversation_id, :turn_id, :role, :kind, :sequence
|
|
9
9
|
attr_reader :content, :tool_execution_id, :provider_message_id, :metadata, :created_at
|
|
@@ -61,6 +61,10 @@ module TurnKit
|
|
|
61
61
|
kind == "image"
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
def media_analysis?
|
|
65
|
+
kind == "media_analysis"
|
|
66
|
+
end
|
|
67
|
+
|
|
64
68
|
def text
|
|
65
69
|
content.filter_map do |part|
|
|
66
70
|
attrs = stringify(part)
|
|
@@ -46,6 +46,8 @@ module TurnKit
|
|
|
46
46
|
{ role: :tool, content: part&.fetch("text", message.text) || message.text, tool_call_id: part&.fetch("tool_call_id", nil) }
|
|
47
47
|
when "image"
|
|
48
48
|
{ role: :assistant, content: projected_images }
|
|
49
|
+
when "media_analysis"
|
|
50
|
+
{ role: :assistant, content: projected_media_analyses }
|
|
49
51
|
else
|
|
50
52
|
{ role: message.role.to_sym, content: message.text }
|
|
51
53
|
end
|
|
@@ -76,5 +78,14 @@ module TurnKit
|
|
|
76
78
|
"Generated image: #{attrs.to_json}"
|
|
77
79
|
end.join("\n")
|
|
78
80
|
end
|
|
81
|
+
|
|
82
|
+
def projected_media_analyses
|
|
83
|
+
message.content.filter_map do |part|
|
|
84
|
+
next unless part.fetch("type") == "media_analysis"
|
|
85
|
+
|
|
86
|
+
media = part.fetch("media", {}).slice("kind", "mime_type", "filename", "url").compact
|
|
87
|
+
[ "Media analysis: #{media.to_json}", part["text"].to_s ].reject(&:empty?).join("\n")
|
|
88
|
+
end.join("\n")
|
|
89
|
+
end
|
|
79
90
|
end
|
|
80
91
|
end
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class ModelRequest
|
|
5
|
-
attr_reader :model, :messages, :tools, :instructions, :thinking, :output_schema, :metadata, :report
|
|
5
|
+
attr_reader :model, :messages, :tools, :instructions, :dynamic_instructions, :thinking, :output_schema, :metadata, :report
|
|
6
6
|
|
|
7
|
-
def initialize(model:, messages:, tools:, instructions:, thinking: nil, output_schema: nil, metadata: {}, report: nil)
|
|
7
|
+
def initialize(model:, messages:, tools:, instructions:, dynamic_instructions: nil, thinking: nil, output_schema: nil, metadata: {}, report: nil)
|
|
8
8
|
@model = model
|
|
9
9
|
@messages = Array(messages)
|
|
10
10
|
@tools = Array(tools)
|
|
11
11
|
@instructions = instructions.to_s
|
|
12
|
+
@dynamic_instructions = dynamic_instructions.to_s
|
|
12
13
|
@thinking = thinking
|
|
13
14
|
@output_schema = output_schema
|
|
14
15
|
@metadata = metadata || {}
|
|
@@ -25,6 +26,7 @@ module TurnKit
|
|
|
25
26
|
"messages" => messages,
|
|
26
27
|
"tools" => tool_names,
|
|
27
28
|
"instructions" => instructions,
|
|
29
|
+
"dynamic_instructions" => dynamic_instructions,
|
|
28
30
|
"thinking" => thinking,
|
|
29
31
|
"output_schema" => output_schema,
|
|
30
32
|
"metadata" => metadata,
|
|
@@ -40,6 +40,15 @@ module TurnKit
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
def self.require_media_analysis
|
|
44
|
+
lambda do |output, output_data: nil, turn: nil, **|
|
|
45
|
+
data = output_data.is_a?(Hash) ? output_data : output
|
|
46
|
+
analyses = data.is_a?(Hash) ? data["media_analyses"] || data[:media_analyses] : nil
|
|
47
|
+
has_analysis = Array(analyses).any? || turn&.conversation&.messages_for_turn(turn)&.any?(&:media_analysis?)
|
|
48
|
+
{ rule: "media_analysis_required", message: "output must include a media analysis result" } unless has_analysis
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
43
52
|
def initialize(content:, name: "output_policy", model: nil, thinking: nil, client: nil)
|
|
44
53
|
@name = name.to_s
|
|
45
54
|
@content = content.to_s
|
|
@@ -66,7 +75,7 @@ module TurnKit
|
|
|
66
75
|
else
|
|
67
76
|
audit_client = client || TurnKit.client
|
|
68
77
|
audit_client.validate!(model: model_name)
|
|
69
|
-
chat(
|
|
78
|
+
audit_client.chat(model: model_name, messages: audit_messages(output), tools: [], instructions: audit_instructions, thinking: thinking, output_schema: DEFAULT_SCHEMA, metadata: { output_policy: name })
|
|
70
79
|
end
|
|
71
80
|
data = result.output_data || parse_json(result.text)
|
|
72
81
|
return if data.fetch("approved", false)
|
|
@@ -102,20 +111,6 @@ module TurnKit
|
|
|
102
111
|
[ { role: :user, content: JSON.generate(output: output) } ]
|
|
103
112
|
end
|
|
104
113
|
|
|
105
|
-
def chat(client, **kwargs)
|
|
106
|
-
accepted = chat_keyword_names(client)
|
|
107
|
-
kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
|
|
108
|
-
client.chat(**kwargs)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def chat_keyword_names(client)
|
|
112
|
-
client.method(:chat).parameters.filter_map do |kind, name|
|
|
113
|
-
return [ :keyrest ] if kind == :keyrest
|
|
114
|
-
|
|
115
|
-
name if %i[key keyreq].include?(kind)
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
114
|
def parse_json(value)
|
|
120
115
|
JSON.parse(extract_json(value.to_s))
|
|
121
116
|
rescue JSON::ParserError
|
data/lib/turnkit/result.rb
CHANGED
|
@@ -40,6 +40,18 @@ module TurnKit
|
|
|
40
40
|
images.any?
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
def media_analyses
|
|
44
|
+
parts.filter_map do |part|
|
|
45
|
+
next unless part["type"] == "media_analysis"
|
|
46
|
+
|
|
47
|
+
MediaAnalysisResult.from_h(part)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def media_analysis?
|
|
52
|
+
media_analyses.any?
|
|
53
|
+
end
|
|
54
|
+
|
|
43
55
|
private
|
|
44
56
|
def synthesize_parts(text:, tool_calls:)
|
|
45
57
|
parts = []
|
data/lib/turnkit/run.rb
CHANGED
|
@@ -11,16 +11,12 @@ module TurnKit
|
|
|
11
11
|
def id = turn.id
|
|
12
12
|
def root_turn_id = turn.root_turn_id
|
|
13
13
|
def status = turn.status
|
|
14
|
-
def output = output_text
|
|
15
14
|
def output_text = turn.output_text
|
|
16
15
|
def output_data = turn.output_data
|
|
17
16
|
def policy_audit = turn.policy_audit
|
|
18
17
|
def policy_clean? = policy_audit.nil? || policy_audit.fetch("clean", false)
|
|
19
18
|
def usage = Usage.from_records(turn_records)
|
|
20
19
|
def cost = Cost.from_records(turn_records)
|
|
21
|
-
def steps = turn_records.length
|
|
22
|
-
def tool_calls = tool_executions
|
|
23
|
-
def persisted? = true
|
|
24
20
|
|
|
25
21
|
def error
|
|
26
22
|
turn.store.load_turn(id)["error"]
|
data/lib/turnkit/store.rb
CHANGED
|
@@ -12,12 +12,10 @@ module TurnKit
|
|
|
12
12
|
def create_turn(_attributes) = raise(NotImplementedError)
|
|
13
13
|
def load_turn(_id) = raise(NotImplementedError)
|
|
14
14
|
def update_turn(_id, _attributes) = raise(NotImplementedError)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
update_turn(id, attributes.merge(status: to))
|
|
20
|
-
end
|
|
15
|
+
# claim_turn is the concurrency-safety point: it must atomically
|
|
16
|
+
# compare-and-set status from `from` to `to` (returning nil when the turn
|
|
17
|
+
# is not in `from`), so concurrent workers cannot both claim a turn.
|
|
18
|
+
def claim_turn(_id, from: "pending", to: "running", **_attributes) = raise(NotImplementedError)
|
|
21
19
|
def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil) = raise(NotImplementedError)
|
|
22
20
|
|
|
23
21
|
def create_tool_execution(_attributes) = raise(NotImplementedError)
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class SubAgentTool < Tool
|
|
5
|
-
parameter :task, :string, required: true, description: "The task for the sub-agent
|
|
6
|
-
parameter :context, :string, required: false, description: "Relevant context for the sub-agent."
|
|
5
|
+
parameter :task, :string, required: true, description: "The complete task for the sub-agent, including all relevant context."
|
|
7
6
|
|
|
8
7
|
def self.for(agent)
|
|
9
8
|
Class.new(self) do
|
|
@@ -18,25 +17,21 @@ module TurnKit
|
|
|
18
17
|
end
|
|
19
18
|
end
|
|
20
19
|
|
|
21
|
-
def call(task:, context:
|
|
20
|
+
def call(task:, context:)
|
|
22
21
|
sub_agent = self.class.agent
|
|
23
|
-
parent_turn =
|
|
24
|
-
|
|
25
|
-
conversation = sub_agent.conversation(metadata: {
|
|
22
|
+
parent_turn = context.turn
|
|
23
|
+
lineage = {
|
|
26
24
|
"parent_conversation_id" => parent_turn.conversation.id,
|
|
27
25
|
"parent_turn_id" => parent_turn.id,
|
|
28
|
-
"parent_tool_execution_id" =>
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"parent_turn_id" => parent_turn.id,
|
|
33
|
-
"parent_tool_execution_id" => turnkit_context.execution.id
|
|
34
|
-
})
|
|
26
|
+
"parent_tool_execution_id" => context.execution.id
|
|
27
|
+
}
|
|
28
|
+
conversation = sub_agent.conversation(metadata: lineage)
|
|
29
|
+
trigger = conversation.say(task, metadata: lineage)
|
|
35
30
|
child = conversation.run!(
|
|
36
31
|
trigger_message_id: trigger.id,
|
|
37
32
|
budget: parent_turn.budget,
|
|
38
33
|
parent_turn: parent_turn,
|
|
39
|
-
parent_tool_execution:
|
|
34
|
+
parent_tool_execution: context.execution,
|
|
40
35
|
depth: parent_turn.depth + 1,
|
|
41
36
|
model: sub_agent.effective_model,
|
|
42
37
|
agent: sub_agent,
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class SystemPrompt
|
|
5
5
|
DEFAULT_SECTIONS = %i[agent instructions behavior loaded_skills available_skills tools subject live_context environment].freeze
|
|
6
|
-
CACHE_BOUNDARY = "<!-- TURNKIT_DYNAMIC_PROMPT_BOUNDARY -->"
|
|
7
6
|
NONE_PROMPT = "You are an assistant running inside TurnKit."
|
|
8
7
|
PROMPT_MODES = %i[full minimal task none].freeze
|
|
9
8
|
MODE_SECTIONS = {
|
|
@@ -13,7 +12,6 @@ module TurnKit
|
|
|
13
12
|
none: []
|
|
14
13
|
}.freeze
|
|
15
14
|
DYNAMIC_SECTIONS = %i[subject live_context environment].freeze
|
|
16
|
-
OVERRIDABLE_SECTIONS = %i[behavior tools].freeze
|
|
17
15
|
|
|
18
16
|
SECTION_METHODS = {
|
|
19
17
|
agent: :agent_section,
|
|
@@ -92,44 +90,28 @@ module TurnKit
|
|
|
92
90
|
raise ArgumentError, "unknown prompt mode: #{@mode}" unless PROMPT_MODES.include?(@mode)
|
|
93
91
|
|
|
94
92
|
@sections = Array(sections || prompt_sections_for_mode)
|
|
95
|
-
@prompt_contribution = nil
|
|
96
93
|
end
|
|
97
94
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
boundary_inserted = false
|
|
106
|
-
sections.each do |section|
|
|
107
|
-
rendered = render(section)
|
|
108
|
-
next if rendered.nil? || rendered.strip.empty?
|
|
109
|
-
|
|
110
|
-
if dynamic_section?(section) && !boundary_inserted
|
|
111
|
-
values << CACHE_BOUNDARY
|
|
112
|
-
boundary_inserted = true
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
values << rendered
|
|
116
|
-
end
|
|
95
|
+
# The prompt splits into a stable part (identical across turns of the same
|
|
96
|
+
# agent, safe to cache) and a dynamic part (subject, live context,
|
|
97
|
+
# environment) recomputed each turn. Adapters that support prompt caching
|
|
98
|
+
# receive both via ModelRequest#instructions and #dynamic_instructions.
|
|
99
|
+
def stable
|
|
100
|
+
parts.fetch(0).join("\n\n")
|
|
101
|
+
end
|
|
117
102
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
end
|
|
103
|
+
def dynamic
|
|
104
|
+
parts.fetch(1).join("\n\n")
|
|
105
|
+
end
|
|
122
106
|
|
|
123
|
-
|
|
107
|
+
def to_s
|
|
108
|
+
[ stable, dynamic ].reject(&:empty?).join("\n\n")
|
|
124
109
|
end
|
|
125
110
|
|
|
126
111
|
def render(section)
|
|
127
112
|
method = SECTION_METHODS[section.to_sym]
|
|
128
113
|
raise ArgumentError, "unknown prompt section: #{section}" unless method
|
|
129
114
|
|
|
130
|
-
override = section_override(section)
|
|
131
|
-
return tagged(section, override) if override
|
|
132
|
-
|
|
133
115
|
public_send(method)
|
|
134
116
|
end
|
|
135
117
|
|
|
@@ -292,11 +274,9 @@ module TurnKit
|
|
|
292
274
|
|
|
293
275
|
def report
|
|
294
276
|
text = to_s
|
|
295
|
-
stable, dynamic = self.class.split_cache_boundary(text)
|
|
296
277
|
{
|
|
297
278
|
"chars" => text.length,
|
|
298
279
|
"hash" => Digest::SHA256.hexdigest(text),
|
|
299
|
-
"has_cache_boundary" => text.include?(CACHE_BOUNDARY),
|
|
300
280
|
"stable_chars" => stable.length,
|
|
301
281
|
"dynamic_chars" => dynamic.length,
|
|
302
282
|
"sections" => sections.map(&:to_s),
|
|
@@ -304,12 +284,27 @@ module TurnKit
|
|
|
304
284
|
}
|
|
305
285
|
end
|
|
306
286
|
|
|
307
|
-
def self.split_cache_boundary(text)
|
|
308
|
-
stable, dynamic = text.to_s.split(CACHE_BOUNDARY, 2)
|
|
309
|
-
[ stable.to_s, dynamic.to_s ]
|
|
310
|
-
end
|
|
311
|
-
|
|
312
287
|
private
|
|
288
|
+
def parts
|
|
289
|
+
@parts ||= build_parts
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def build_parts
|
|
293
|
+
return [ [ NONE_PROMPT ], [] ] if mode == :none
|
|
294
|
+
|
|
295
|
+
stable_parts = []
|
|
296
|
+
dynamic_parts = []
|
|
297
|
+
target = stable_parts
|
|
298
|
+
sections.each do |section|
|
|
299
|
+
rendered = render(section)
|
|
300
|
+
next if rendered.nil? || rendered.strip.empty?
|
|
301
|
+
|
|
302
|
+
target = dynamic_parts if dynamic_section?(section)
|
|
303
|
+
target << rendered
|
|
304
|
+
end
|
|
305
|
+
[ stable_parts, dynamic_parts ]
|
|
306
|
+
end
|
|
307
|
+
|
|
313
308
|
def tagged(name, content)
|
|
314
309
|
"<#{name}>\n#{content}\n</#{name}>"
|
|
315
310
|
end
|
|
@@ -383,64 +378,5 @@ module TurnKit
|
|
|
383
378
|
end
|
|
384
379
|
end
|
|
385
380
|
|
|
386
|
-
def prompt_contribution
|
|
387
|
-
@prompt_contribution ||= merge_prompt_contributions(resolve_prompt_contributions)
|
|
388
|
-
end
|
|
389
|
-
|
|
390
|
-
def resolve_prompt_contributions
|
|
391
|
-
contributors = Array(TurnKit.system_prompt_contributors)
|
|
392
|
-
contributors += matching_model_prompt_contributors
|
|
393
|
-
contributors.filter_map do |contributor|
|
|
394
|
-
value = contributor.respond_to?(:call) ? contributor.call(prompt_build_context) : contributor
|
|
395
|
-
normalize_prompt_contribution(value)
|
|
396
|
-
end
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
def matching_model_prompt_contributors
|
|
400
|
-
model_name = (turn.model || agent.effective_model).to_s
|
|
401
|
-
TurnKit.model_prompt_contributors.flat_map do |matcher, contributor|
|
|
402
|
-
matches = case matcher
|
|
403
|
-
when Regexp
|
|
404
|
-
matcher.match?(model_name)
|
|
405
|
-
else
|
|
406
|
-
matcher.to_s == model_name
|
|
407
|
-
end
|
|
408
|
-
matches ? Array(contributor) : []
|
|
409
|
-
end
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
def normalize_prompt_contribution(value)
|
|
413
|
-
case value
|
|
414
|
-
when nil, false
|
|
415
|
-
nil
|
|
416
|
-
when PromptContribution
|
|
417
|
-
value
|
|
418
|
-
when Hash
|
|
419
|
-
PromptContribution.new(
|
|
420
|
-
stable_prefix: value[:stable_prefix] || value["stable_prefix"],
|
|
421
|
-
dynamic_suffix: value[:dynamic_suffix] || value["dynamic_suffix"],
|
|
422
|
-
section_overrides: value[:section_overrides] || value["section_overrides"]
|
|
423
|
-
)
|
|
424
|
-
else
|
|
425
|
-
PromptContribution.new(stable_prefix: value.to_s)
|
|
426
|
-
end
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
def merge_prompt_contributions(contributions)
|
|
430
|
-
stable_prefix = contributions.map(&:stable_prefix).reject(&:empty?).join("\n\n")
|
|
431
|
-
dynamic_suffix = contributions.map(&:dynamic_suffix).reject(&:empty?).join("\n\n")
|
|
432
|
-
section_overrides = contributions.each_with_object({}) do |contribution, overrides|
|
|
433
|
-
overrides.merge!(contribution.section_overrides)
|
|
434
|
-
end
|
|
435
|
-
PromptContribution.new(stable_prefix: stable_prefix, dynamic_suffix: dynamic_suffix, section_overrides: section_overrides)
|
|
436
|
-
end
|
|
437
|
-
|
|
438
|
-
def section_override(section)
|
|
439
|
-
key = section.to_sym
|
|
440
|
-
return nil unless OVERRIDABLE_SECTIONS.include?(key)
|
|
441
|
-
|
|
442
|
-
value = prompt_contribution.section_overrides[key]
|
|
443
|
-
value.to_s unless value.nil?
|
|
444
|
-
end
|
|
445
381
|
end
|
|
446
382
|
end
|