turnkit 0.4.1 → 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 +18 -0
- data/README.md +111 -50
- 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 +30 -46
- data/lib/turnkit/agent.rb +43 -10
- data/lib/turnkit/budget.rb +1 -1
- data/lib/turnkit/client.rb +5 -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 +0 -2
- data/lib/turnkit/model_request.rb +4 -2
- data/lib/turnkit/output_policy.rb +1 -15
- 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 +73 -89
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit/view_media_tool.rb +2 -2
- data/lib/turnkit.rb +1 -18
- metadata +15 -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
|
@@ -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
|
data/lib/turnkit/tool.rb
CHANGED
|
@@ -25,6 +25,7 @@ module TurnKit
|
|
|
25
25
|
name = name.to_s
|
|
26
26
|
raise ArgumentError, "unknown parameter type: #{type}" unless TYPES.include?(type)
|
|
27
27
|
raise ArgumentError, "invalid parameter name: #{name}" unless NAME_PATTERN.match?(name)
|
|
28
|
+
raise ArgumentError, "context is a reserved parameter name" if name == "context"
|
|
28
29
|
raise ArgumentError, "duplicate parameter: #{name}" if parameters.any? { |param| param.fetch(:name) == name }
|
|
29
30
|
raise ArgumentError, "enum values are required for enum parameter: #{name}" if type == :enum && Array(enum).empty?
|
|
30
31
|
|
|
@@ -113,23 +114,15 @@ module TurnKit
|
|
|
113
114
|
end
|
|
114
115
|
|
|
115
116
|
def call(arguments = {}, context:)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
rescue ArgumentError => error
|
|
119
|
-
raise if error.message !~ /wrong number of arguments|missing keyword/
|
|
117
|
+
required = instance_method(:initialize).parameters.any? { |kind, _| %i[req keyreq].include?(kind) }
|
|
118
|
+
raise ToolError, "#{tool_name} requires constructor arguments; register an instance instead" if required
|
|
120
119
|
|
|
121
|
-
|
|
122
|
-
end
|
|
123
|
-
invoke(instance, arguments, context: context)
|
|
120
|
+
invoke(new, arguments, context: context)
|
|
124
121
|
end
|
|
125
122
|
|
|
126
123
|
def invoke(instance, arguments = {}, context:)
|
|
127
124
|
keyword_arguments = symbolize(validate_arguments(arguments))
|
|
128
|
-
|
|
129
|
-
instance.call(**keyword_arguments, turnkit_context: context)
|
|
130
|
-
else
|
|
131
|
-
instance.call(**keyword_arguments, context: context)
|
|
132
|
-
end
|
|
125
|
+
instance.call(**keyword_arguments, context: context)
|
|
133
126
|
end
|
|
134
127
|
|
|
135
128
|
private
|
|
@@ -172,10 +165,6 @@ module TurnKit
|
|
|
172
165
|
SchemaCheck.validate!(value, schema_for(param), error_class: ToolValidationError, label: param.fetch(:name))
|
|
173
166
|
end
|
|
174
167
|
|
|
175
|
-
def accepts_turnkit_context?(instance)
|
|
176
|
-
instance.method(:call).parameters.any? { |kind, name| %i[key keyreq].include?(kind) && name == :turnkit_context }
|
|
177
|
-
end
|
|
178
|
-
|
|
179
168
|
def symbolize(hash)
|
|
180
169
|
hash.transform_keys(&:to_sym)
|
|
181
170
|
end
|
data/lib/turnkit/turn.rb
CHANGED
|
@@ -43,7 +43,7 @@ module TurnKit
|
|
|
43
43
|
@started_at = @record["started_at"]
|
|
44
44
|
emit("turn.started", status: status, model: model)
|
|
45
45
|
agent.effective_client.validate!(model: model)
|
|
46
|
-
@budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: budget_limits)
|
|
46
|
+
@budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
|
|
47
47
|
revisions_used = 0
|
|
48
48
|
loop do
|
|
49
49
|
budget.check!(depth: depth)
|
|
@@ -113,7 +113,15 @@ module TurnKit
|
|
|
113
113
|
end
|
|
114
114
|
|
|
115
115
|
def policy_audit
|
|
116
|
-
|
|
116
|
+
options = @record["options"] || {}
|
|
117
|
+
options.dig("state", "policy_audit") || options["policy_audit"]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Reads iterations from options["state"], falling back to the legacy
|
|
121
|
+
# top-level key for turns persisted before the state split.
|
|
122
|
+
def self.iterations_for(record)
|
|
123
|
+
options = record["options"] || {}
|
|
124
|
+
(options.dig("state", "iterations") || options["iterations"]).to_i
|
|
117
125
|
end
|
|
118
126
|
|
|
119
127
|
def usage
|
|
@@ -168,23 +176,13 @@ module TurnKit
|
|
|
168
176
|
end
|
|
169
177
|
|
|
170
178
|
def paint(prompt, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: {}, client: nil)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
when "pending"
|
|
174
|
-
claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
|
|
175
|
-
raise Error, "turn is already running" unless claimed
|
|
176
|
-
|
|
179
|
+
claimed = claim_standalone!("paint")
|
|
180
|
+
if claimed
|
|
177
181
|
@record = claimed
|
|
178
182
|
@started_at = @record["started_at"]
|
|
179
|
-
@budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: budget_limits)
|
|
180
|
-
claimed_standalone = true
|
|
181
183
|
emit("turn.started", status: status, model: model)
|
|
182
|
-
|
|
183
|
-
# Image tools call this while their parent turn is running.
|
|
184
|
-
else
|
|
185
|
-
raise Error, "cannot paint for #{status} turn"
|
|
184
|
+
@budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
|
|
186
185
|
end
|
|
187
|
-
|
|
188
186
|
image_client = client || agent.effective_client
|
|
189
187
|
request = {
|
|
190
188
|
prompt: prompt,
|
|
@@ -201,43 +199,28 @@ module TurnKit
|
|
|
201
199
|
image_client.validate!(model: model)
|
|
202
200
|
emit("image.requested", request.except(:input_images, :mask))
|
|
203
201
|
result = call_image_client(image_client, request)
|
|
204
|
-
result_cost =
|
|
205
|
-
add_usage!(result.usage, cost: result_cost)
|
|
206
|
-
budget.add_cost!(result_cost.total)
|
|
202
|
+
result_cost = apply_result_cost(result, model: model)
|
|
207
203
|
image = result.images.first
|
|
208
204
|
raise Error, "image client returned no image" unless image
|
|
209
205
|
raise Error, "image client returned image without url or data" if image.url.to_s.empty? && image.data.to_s.empty?
|
|
210
206
|
|
|
211
207
|
persist_image_message(image)
|
|
212
208
|
emit("image.completed", image: image.to_h, model: image.model || model, provider: image.provider || provider&.to_s, mime_type: image.mime_type, usage: result.usage.to_h, cost: result_cost.to_h, metadata: metadata || {})
|
|
213
|
-
complete_with_output(image.url.to_s, output_data: { "type" => "image", "images" => [ image.to_h ] }, audit: check_policy(image.url.to_s, output_data: { "type" => "image", "images" => [ image.to_h ] })) if
|
|
209
|
+
complete_with_output(image.url.to_s, output_data: { "type" => "image", "images" => [ image.to_h ] }, audit: check_policy(image.url.to_s, output_data: { "type" => "image", "images" => [ image.to_h ] })) if claimed
|
|
214
210
|
image
|
|
215
211
|
rescue StandardError => error
|
|
216
|
-
if
|
|
217
|
-
update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
|
|
218
|
-
emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
|
|
219
|
-
end
|
|
212
|
+
fail_standalone!(error) if claimed
|
|
220
213
|
raise
|
|
221
214
|
end
|
|
222
215
|
|
|
223
216
|
def view_media(media, objective:, model:, provider: nil, output_schema: nil, params: {}, metadata: {}, client: nil)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
when "pending"
|
|
227
|
-
claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
|
|
228
|
-
raise Error, "turn is already running" unless claimed
|
|
229
|
-
|
|
217
|
+
claimed = claim_standalone!("view media")
|
|
218
|
+
if claimed
|
|
230
219
|
@record = claimed
|
|
231
220
|
@started_at = @record["started_at"]
|
|
232
|
-
@budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: budget_limits)
|
|
233
|
-
claimed_standalone = true
|
|
234
221
|
emit("turn.started", status: status, model: model)
|
|
235
|
-
|
|
236
|
-
# Media tools call this while their parent turn is running.
|
|
237
|
-
else
|
|
238
|
-
raise Error, "cannot view media for #{status} turn"
|
|
222
|
+
@budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
|
|
239
223
|
end
|
|
240
|
-
|
|
241
224
|
media_input = MediaInput.wrap(media)
|
|
242
225
|
media_client = client || agent.effective_client
|
|
243
226
|
request = {
|
|
@@ -253,42 +236,38 @@ module TurnKit
|
|
|
253
236
|
media_client.validate!(model: model)
|
|
254
237
|
emit("media.requested", request.except(:media).merge(media: media_input.to_h))
|
|
255
238
|
result = call_media_client(media_client, request)
|
|
256
|
-
result_cost =
|
|
257
|
-
add_usage!(result.usage, cost: result_cost)
|
|
258
|
-
budget.add_cost!(result_cost.total)
|
|
239
|
+
result_cost = apply_result_cost(result, model: model)
|
|
259
240
|
analysis = result.media_analyses.first
|
|
260
241
|
raise Error, "media client returned no media analysis" unless analysis
|
|
261
242
|
|
|
262
243
|
persist_media_analysis_message(analysis)
|
|
263
244
|
output_data = { "type" => "media_analysis", "media_analyses" => [ analysis.to_h ] }
|
|
264
245
|
emit("media.completed", analysis: analysis.to_h, model: analysis.model || model, provider: analysis.provider || provider&.to_s, media: media_input.to_h, usage: result.usage.to_h, cost: result_cost.to_h, metadata: metadata || {})
|
|
265
|
-
complete_with_output(analysis.text, output_data: output_data, audit: check_policy(analysis.text, output_data: output_data)) if
|
|
246
|
+
complete_with_output(analysis.text, output_data: output_data, audit: check_policy(analysis.text, output_data: output_data)) if claimed
|
|
266
247
|
analysis
|
|
267
248
|
rescue StandardError => error
|
|
268
|
-
emit("media.failed", error: { "class" => error.class.name, "message" => error.message }, metadata: metadata || {}) if status == "running" ||
|
|
269
|
-
if
|
|
270
|
-
update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
|
|
271
|
-
emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
|
|
272
|
-
end
|
|
249
|
+
emit("media.failed", error: { "class" => error.class.name, "message" => error.message }, metadata: metadata || {}) if status == "running" || claimed
|
|
250
|
+
fail_standalone!(error) if claimed
|
|
273
251
|
raise
|
|
274
252
|
end
|
|
275
253
|
|
|
276
254
|
private
|
|
277
255
|
def model_request
|
|
278
256
|
prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
|
|
279
|
-
instructions = case agent.system_prompt
|
|
257
|
+
instructions, dynamic_instructions = case agent.system_prompt
|
|
280
258
|
when nil
|
|
281
|
-
prompt.
|
|
259
|
+
[ prompt.stable, prompt.dynamic ]
|
|
282
260
|
when String
|
|
283
|
-
agent.system_prompt
|
|
261
|
+
[ agent.system_prompt, nil ]
|
|
284
262
|
else
|
|
285
|
-
agent.system_prompt.call(prompt).to_s
|
|
263
|
+
[ agent.system_prompt.call(prompt).to_s, nil ]
|
|
286
264
|
end
|
|
287
265
|
ModelRequest.new(
|
|
288
266
|
model: model,
|
|
289
267
|
messages: llm_messages,
|
|
290
268
|
tools: agent.effective_tools,
|
|
291
269
|
instructions: instructions,
|
|
270
|
+
dynamic_instructions: dynamic_instructions,
|
|
292
271
|
thinking: thinking,
|
|
293
272
|
output_schema: output_schema,
|
|
294
273
|
metadata: { turn_id: id, conversation_id: conversation.id },
|
|
@@ -296,48 +275,27 @@ module TurnKit
|
|
|
296
275
|
)
|
|
297
276
|
end
|
|
298
277
|
|
|
278
|
+
# Clients implement the TurnKit::Client keyword contract. See client.rb.
|
|
299
279
|
def call_client(request, client: agent.effective_client)
|
|
300
|
-
|
|
280
|
+
client.chat(
|
|
301
281
|
model: request.model,
|
|
302
282
|
messages: request.messages,
|
|
303
283
|
tools: request.tools,
|
|
304
284
|
instructions: request.instructions,
|
|
285
|
+
dynamic_instructions: request.dynamic_instructions,
|
|
305
286
|
thinking: request.thinking,
|
|
306
287
|
output_schema: request.output_schema,
|
|
307
288
|
metadata: request.metadata,
|
|
308
289
|
on_event: ->(event) { emit_event(event) }
|
|
309
|
-
|
|
310
|
-
accepted = chat_keyword_names(client)
|
|
311
|
-
kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
|
|
312
|
-
client.chat(**kwargs)
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
def chat_keyword_names(client)
|
|
316
|
-
client.method(:chat).parameters.filter_map do |kind, name|
|
|
317
|
-
return [ :keyrest ] if kind == :keyrest
|
|
318
|
-
|
|
319
|
-
name if %i[key keyreq].include?(kind)
|
|
320
|
-
end
|
|
290
|
+
)
|
|
321
291
|
end
|
|
322
292
|
|
|
323
293
|
def call_image_client(client, request)
|
|
324
|
-
|
|
325
|
-
accepted = client.method(:paint).parameters.filter_map do |kind, name|
|
|
326
|
-
return client.paint(**kwargs) if kind == :keyrest
|
|
327
|
-
|
|
328
|
-
name if %i[key keyreq].include?(kind)
|
|
329
|
-
end
|
|
330
|
-
client.paint(**kwargs.slice(*accepted))
|
|
294
|
+
client.paint(**request, on_event: ->(event) { emit_event(event) })
|
|
331
295
|
end
|
|
332
296
|
|
|
333
297
|
def call_media_client(client, request)
|
|
334
|
-
|
|
335
|
-
accepted = client.method(:view_media).parameters.filter_map do |kind, name|
|
|
336
|
-
return client.view_media(**kwargs) if kind == :keyrest
|
|
337
|
-
|
|
338
|
-
name if %i[key keyreq].include?(kind)
|
|
339
|
-
end
|
|
340
|
-
client.view_media(**kwargs.slice(*accepted))
|
|
298
|
+
client.view_media(**request, on_event: ->(event) { emit_event(event) })
|
|
341
299
|
end
|
|
342
300
|
|
|
343
301
|
def llm_messages
|
|
@@ -453,8 +411,7 @@ module TurnKit
|
|
|
453
411
|
end
|
|
454
412
|
|
|
455
413
|
def persist_policy_audit(audit)
|
|
456
|
-
|
|
457
|
-
update!(options: options)
|
|
414
|
+
update_state!("policy_audit" => audit.to_h)
|
|
458
415
|
emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length)
|
|
459
416
|
end
|
|
460
417
|
|
|
@@ -506,23 +463,50 @@ module TurnKit
|
|
|
506
463
|
|
|
507
464
|
def count_iteration!
|
|
508
465
|
budget.count_iteration!
|
|
509
|
-
|
|
510
|
-
|
|
466
|
+
update_state!("iterations" => Turn.iterations_for(@record) + 1)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Runtime state lives under options["state"]; the rest of options is
|
|
470
|
+
# write-once turn configuration. Reads fall back to the legacy top-level
|
|
471
|
+
# keys for turns persisted before the split.
|
|
472
|
+
def update_state!(changes)
|
|
473
|
+
options = @record["options"] || {}
|
|
474
|
+
update!(options: options.merge("state" => (options["state"] || {}).merge(changes)))
|
|
511
475
|
end
|
|
512
476
|
|
|
513
477
|
def heartbeat!
|
|
514
478
|
update!(heartbeat_at: Clock.now)
|
|
515
479
|
end
|
|
516
480
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
481
|
+
# Claims a pending turn for a standalone media call. Returns the claimed
|
|
482
|
+
# record when this call owns turn completion, nil when running inside a parent
|
|
483
|
+
# turn (media tools call paint/view_media while their turn is running).
|
|
484
|
+
# Callers perform any post-claim setup after assignment, so later errors
|
|
485
|
+
# still fail the claimed turn.
|
|
486
|
+
def claim_standalone!(action)
|
|
487
|
+
case status
|
|
488
|
+
when "pending"
|
|
489
|
+
claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
|
|
490
|
+
raise Error, "turn is already running" unless claimed
|
|
491
|
+
|
|
492
|
+
claimed
|
|
493
|
+
when "running"
|
|
494
|
+
nil
|
|
495
|
+
else
|
|
496
|
+
raise Error, "cannot #{action} for #{status} turn"
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def fail_standalone!(error)
|
|
501
|
+
update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
|
|
502
|
+
emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def apply_result_cost(result, model:)
|
|
506
|
+
cost = Cost.from_usage(result.usage, model: result.model || model)
|
|
507
|
+
add_usage!(result.usage, cost: cost)
|
|
508
|
+
budget.add_cost!(cost.total)
|
|
509
|
+
cost
|
|
526
510
|
end
|
|
527
511
|
|
|
528
512
|
def aggregate_cost(current, cost)
|
data/lib/turnkit/version.rb
CHANGED
|
@@ -11,8 +11,8 @@ module TurnKit
|
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def call(
|
|
15
|
-
|
|
14
|
+
def call(context:, **arguments)
|
|
15
|
+
context.turn.view_media(
|
|
16
16
|
media(**arguments),
|
|
17
17
|
objective: objective(**arguments),
|
|
18
18
|
model: self.class.model,
|
data/lib/turnkit.rb
CHANGED
|
@@ -17,7 +17,6 @@ require_relative "turnkit/event"
|
|
|
17
17
|
require_relative "turnkit/model_request"
|
|
18
18
|
require_relative "turnkit/schema_check"
|
|
19
19
|
require_relative "turnkit/agent"
|
|
20
|
-
require_relative "turnkit/workflow"
|
|
21
20
|
require_relative "turnkit/client"
|
|
22
21
|
require_relative "turnkit/conversation"
|
|
23
22
|
require_relative "turnkit/message"
|
|
@@ -31,7 +30,6 @@ require_relative "turnkit/output_audit"
|
|
|
31
30
|
require_relative "turnkit/output_policy"
|
|
32
31
|
require_relative "turnkit/prompt_data"
|
|
33
32
|
require_relative "turnkit/prompt_context"
|
|
34
|
-
require_relative "turnkit/prompt_contribution"
|
|
35
33
|
require_relative "turnkit/system_prompt"
|
|
36
34
|
require_relative "turnkit/store"
|
|
37
35
|
require_relative "turnkit/memory_store"
|
|
@@ -50,9 +48,7 @@ require_relative "turnkit/usage"
|
|
|
50
48
|
require_relative "turnkit/run"
|
|
51
49
|
require_relative "turnkit/adapters/codex"
|
|
52
50
|
require_relative "turnkit/adapters/ruby_llm"
|
|
53
|
-
require_relative "turnkit/
|
|
54
|
-
|
|
55
|
-
require_relative "turnkit/rails/railtie" if defined?(Rails)
|
|
51
|
+
require_relative "turnkit/active_record_store"
|
|
56
52
|
|
|
57
53
|
module TurnKit
|
|
58
54
|
class << self
|
|
@@ -65,10 +61,7 @@ module TurnKit
|
|
|
65
61
|
attr_accessor :cost_rates, :cost_calculator
|
|
66
62
|
attr_accessor :prompt_sections, :prompt_behavior, :available_skills
|
|
67
63
|
attr_accessor :prompt_data_max_chars, :context_contributors
|
|
68
|
-
attr_accessor :system_prompt_contributors, :model_prompt_contributors
|
|
69
64
|
attr_accessor :on_event
|
|
70
|
-
attr_accessor :conversation_record_class, :turn_record_class
|
|
71
|
-
attr_accessor :message_record_class, :tool_execution_record_class
|
|
72
65
|
end
|
|
73
66
|
|
|
74
67
|
self.default_model = "claude-sonnet-4-5"
|
|
@@ -87,8 +80,6 @@ module TurnKit
|
|
|
87
80
|
self.prompt_data_max_chars = 20_000
|
|
88
81
|
self.available_skills = []
|
|
89
82
|
self.context_contributors = []
|
|
90
|
-
self.system_prompt_contributors = []
|
|
91
|
-
self.model_prompt_contributors = {}
|
|
92
83
|
self.on_event = nil
|
|
93
84
|
self.output_policy_model = nil
|
|
94
85
|
self.output_policy_thinking = { effort: :low }
|
|
@@ -97,14 +88,6 @@ module TurnKit
|
|
|
97
88
|
yield self
|
|
98
89
|
end
|
|
99
90
|
|
|
100
|
-
def self.model
|
|
101
|
-
default_model
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def self.model=(value)
|
|
105
|
-
self.default_model = value
|
|
106
|
-
end
|
|
107
|
-
|
|
108
91
|
def self.reconcile_stale!(before: Clock.now - (timeout || 300))
|
|
109
92
|
store.find_stale_turns(before: before).each do |turn|
|
|
110
93
|
store.update_turn(turn.fetch("id"), "status" => "stale", "completed_at" => Clock.now)
|