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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +170 -49
  4. data/UPGRADE.md +29 -0
  5. data/UPGRADE_TO_0_4_2.md +425 -0
  6. data/lib/{turnkit/generators → generators}/turnkit/install/templates/initializer.rb +7 -6
  7. data/lib/turnkit/{stores/active_record_store.rb → active_record_store.rb} +18 -4
  8. data/lib/turnkit/adapters/codex.rb +8 -11
  9. data/lib/turnkit/adapters/ruby_llm.rb +68 -44
  10. data/lib/turnkit/agent.rb +43 -10
  11. data/lib/turnkit/budget.rb +1 -1
  12. data/lib/turnkit/client.rb +9 -1
  13. data/lib/turnkit/compaction.rb +46 -46
  14. data/lib/turnkit/cost.rb +29 -31
  15. data/lib/turnkit/image_result.rb +1 -0
  16. data/lib/turnkit/image_tool.rb +2 -2
  17. data/lib/turnkit/media_analysis_result.rb +46 -0
  18. data/lib/turnkit/media_input.rb +208 -0
  19. data/lib/turnkit/message.rb +5 -1
  20. data/lib/turnkit/message_projection.rb +11 -0
  21. data/lib/turnkit/model_request.rb +4 -2
  22. data/lib/turnkit/output_policy.rb +10 -15
  23. data/lib/turnkit/result.rb +12 -0
  24. data/lib/turnkit/run.rb +0 -4
  25. data/lib/turnkit/store.rb +4 -6
  26. data/lib/turnkit/sub_agent_tool.rb +9 -14
  27. data/lib/turnkit/system_prompt.rb +32 -96
  28. data/lib/turnkit/tool.rb +5 -16
  29. data/lib/turnkit/turn.rb +113 -58
  30. data/lib/turnkit/version.rb +1 -1
  31. data/lib/turnkit/view_media_tool.rb +30 -0
  32. data/lib/turnkit.rb +9 -18
  33. metadata +18 -30
  34. data/lib/turnkit/prompt_contribution.rb +0 -13
  35. data/lib/turnkit/rails/railtie.rb +0 -9
  36. data/lib/turnkit/workflow.rb +0 -58
  37. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/conversation.rb +0 -0
  38. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/create_turnkit_tables.rb +0 -0
  39. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/message.rb +0 -0
  40. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/tool_execution.rb +0 -0
  41. /data/lib/{turnkit/generators → generators}/turnkit/install/templates/turn.rb +0 -0
  42. /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
@@ -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(audit_client, model: model_name, messages: audit_messages(output), tools: [], instructions: audit_instructions, thinking: thinking, output_schema: DEFAULT_SCHEMA, metadata: { output_policy: name })
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
@@ -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
- def claim_turn(id, from: "pending", to: "running", **attributes)
16
- turn = load_turn(id)
17
- return nil unless turn["status"] == from
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 to complete."
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: nil, turnkit_context:)
20
+ def call(task:, context:)
22
21
  sub_agent = self.class.agent
23
- parent_turn = turnkit_context.turn
24
- prompt = [ task, context ].compact.join("\n\n")
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" => turnkit_context.execution.id
29
- })
30
- trigger = conversation.say(prompt, metadata: {
31
- "parent_conversation_id" => parent_turn.conversation.id,
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: turnkit_context.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
- def to_s
99
- return NONE_PROMPT if mode == :none
100
-
101
- values = []
102
- contribution = prompt_contribution
103
- values << contribution.stable_prefix unless contribution.stable_prefix.empty?
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
- unless contribution.dynamic_suffix.empty?
119
- values << CACHE_BOUNDARY unless boundary_inserted
120
- values << contribution.dynamic_suffix
121
- end
103
+ def dynamic
104
+ parts.fetch(1).join("\n\n")
105
+ end
122
106
 
123
- values.compact.reject { |value| value.strip.empty? }.join("\n\n")
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