turnkit 0.2.10 → 0.4.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 +16 -0
- data/README.md +89 -9
- data/UPGRADE.md +37 -299
- data/lib/turnkit/adapters/ruby_llm.rb +87 -0
- data/lib/turnkit/agent.rb +22 -33
- data/lib/turnkit/budget.rb +24 -5
- data/lib/turnkit/client.rb +4 -0
- data/lib/turnkit/compaction.rb +1 -0
- data/lib/turnkit/error.rb +1 -0
- data/lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb +0 -1
- data/lib/turnkit/image_result.rb +51 -0
- data/lib/turnkit/image_tool.rb +30 -0
- data/lib/turnkit/load_skill_tool.rb +29 -0
- data/lib/turnkit/memory_store.rb +11 -0
- data/lib/turnkit/message.rb +19 -8
- data/lib/turnkit/message_projection.rb +28 -2
- data/lib/turnkit/output_policy.rb +15 -0
- data/lib/turnkit/result.rb +41 -4
- data/lib/turnkit/run.rb +4 -5
- data/lib/turnkit/schema_check.rb +68 -0
- data/lib/turnkit/skill.rb +16 -2
- data/lib/turnkit/store.rb +6 -0
- data/lib/turnkit/stores/active_record_store.rb +10 -2
- data/lib/turnkit/sub_agent_tool.rb +2 -1
- data/lib/turnkit/system_prompt.rb +1 -1
- data/lib/turnkit/tool.rb +2 -21
- data/lib/turnkit/tool_call.rb +3 -3
- data/lib/turnkit/tool_runner.rb +41 -16
- data/lib/turnkit/turn.rb +161 -23
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit/workflow.rb +20 -94
- data/lib/turnkit.rb +12 -10
- metadata +6 -2
data/lib/turnkit/agent.rb
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Agent
|
|
5
5
|
attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
|
|
6
|
-
attr_reader :client, :store, :max_iterations, :timeout, :
|
|
7
|
-
attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :on_event
|
|
8
|
-
attr_reader :
|
|
6
|
+
attr_reader :client, :store, :max_iterations, :timeout, :max_spend, :max_depth, :max_tool_executions, :max_tool_executions_by_name
|
|
7
|
+
attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :input_schema, :on_event
|
|
8
|
+
attr_reader :output_policy, :output_policy_mode, :output_policy_model, :output_retries
|
|
9
9
|
|
|
10
10
|
def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], available_skills: [], sub_agents: [],
|
|
11
11
|
system_prompt: nil, prompt_sections: nil, prompt_mode: nil, client: nil, store: nil,
|
|
12
|
-
max_iterations: nil, timeout: nil,
|
|
13
|
-
output_schema: nil,
|
|
12
|
+
max_iterations: nil, timeout: nil, max_spend: nil, max_depth: nil, max_tool_executions: nil, max_tool_executions_by_name: nil, thinking: nil, compaction: nil,
|
|
13
|
+
output_schema: nil, input_schema: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, output_retries: 0, on_event: nil)
|
|
14
14
|
@name = name.to_s
|
|
15
15
|
@description = description.to_s
|
|
16
16
|
@model = model
|
|
@@ -26,16 +26,18 @@ module TurnKit
|
|
|
26
26
|
@store = store
|
|
27
27
|
@max_iterations = max_iterations
|
|
28
28
|
@timeout = timeout
|
|
29
|
-
@
|
|
29
|
+
@max_spend = max_spend
|
|
30
30
|
@max_depth = max_depth
|
|
31
31
|
@max_tool_executions = max_tool_executions
|
|
32
32
|
@max_tool_executions_by_name = max_tool_executions_by_name
|
|
33
33
|
@thinking = self.class.normalize_thinking(thinking)
|
|
34
34
|
@compaction = compaction
|
|
35
35
|
@output_schema = output_schema
|
|
36
|
+
@input_schema = input_schema
|
|
36
37
|
@output_policy_model = output_policy_model
|
|
37
|
-
@
|
|
38
|
-
@
|
|
38
|
+
@output_policy = normalize_output_policy(output_policy, model: output_policy_model, thinking: output_policy_thinking)
|
|
39
|
+
@output_policy_mode = normalize_output_policy_mode(output_policy_mode)
|
|
40
|
+
@output_retries = Integer(output_retries || 0)
|
|
39
41
|
@on_event = on_event
|
|
40
42
|
raise ArgumentError, "name is required" if @name.empty?
|
|
41
43
|
validate_tools!
|
|
@@ -70,6 +72,7 @@ module TurnKit
|
|
|
70
72
|
def run(prompt = nil, task: nil, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, prompt_mode: :task, **options)
|
|
71
73
|
task = task || prompt
|
|
72
74
|
raise ArgumentError, "task is required" if task.to_s.empty?
|
|
75
|
+
SchemaCheck.validate!(input, input_schema, error_class: InputError, label: "input") if input_schema
|
|
73
76
|
|
|
74
77
|
conversation = self.conversation(subject: subject, metadata: metadata)
|
|
75
78
|
message = conversation.say(task_message(task, input), metadata: { "source" => "application", "task" => true })
|
|
@@ -99,16 +102,8 @@ module TurnKit
|
|
|
99
102
|
thinking
|
|
100
103
|
end
|
|
101
104
|
|
|
102
|
-
def
|
|
103
|
-
Array(
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def output_policy
|
|
107
|
-
output_audit
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def output_policy_mode
|
|
111
|
-
output_audit_mode
|
|
105
|
+
def effective_output_policy
|
|
106
|
+
Array(output_policy).compact
|
|
112
107
|
end
|
|
113
108
|
|
|
114
109
|
def effective_client
|
|
@@ -120,7 +115,9 @@ module TurnKit
|
|
|
120
115
|
end
|
|
121
116
|
|
|
122
117
|
def effective_tools
|
|
123
|
-
tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
|
|
118
|
+
configured = tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
|
|
119
|
+
skills = effective_available_skills
|
|
120
|
+
skills.empty? ? configured : configured + [ LoadSkillTool.for(skills) ]
|
|
124
121
|
end
|
|
125
122
|
|
|
126
123
|
def effective_on_event
|
|
@@ -161,7 +158,7 @@ module TurnKit
|
|
|
161
158
|
max_depth: max_depth || TurnKit.max_depth,
|
|
162
159
|
max_tool_executions: max_tool_executions || TurnKit.max_tool_executions,
|
|
163
160
|
max_tool_executions_by_name: max_tool_executions_by_name || TurnKit.max_tool_executions_by_name,
|
|
164
|
-
|
|
161
|
+
max_spend: max_spend || TurnKit.max_spend,
|
|
165
162
|
root_started_at: root_started_at
|
|
166
163
|
)
|
|
167
164
|
end
|
|
@@ -188,12 +185,6 @@ module TurnKit
|
|
|
188
185
|
effective_tools.each(&:validate_definition!)
|
|
189
186
|
end
|
|
190
187
|
|
|
191
|
-
def normalize_output_policy_options(output_audit:, output_policy:, output_policy_model:, output_policy_thinking:)
|
|
192
|
-
raise ArgumentError, "use output_policy: or output_audit:, not both" if output_audit && output_policy
|
|
193
|
-
|
|
194
|
-
output_policy.nil? ? output_audit : normalize_output_policy(output_policy, model: output_policy_model, thinking: output_policy_thinking)
|
|
195
|
-
end
|
|
196
|
-
|
|
197
188
|
def normalize_output_policy(value, model: nil, thinking: nil)
|
|
198
189
|
case value
|
|
199
190
|
when nil
|
|
@@ -204,10 +195,12 @@ module TurnKit
|
|
|
204
195
|
output_policy_from_path(value, model: model, thinking: thinking)
|
|
205
196
|
when Pathname
|
|
206
197
|
output_policy_from_path(value.to_s, model: model, thinking: thinking)
|
|
198
|
+
when Skill
|
|
199
|
+
OutputPolicy.from_skill(value, model: model || TurnKit.output_policy_model, thinking: thinking || TurnKit.output_policy_thinking)
|
|
207
200
|
else
|
|
208
201
|
return value if value.respond_to?(:call) || value.respond_to?(:check)
|
|
209
202
|
|
|
210
|
-
raise ArgumentError, "output_policy must be a policy file path, a #call/#check object, or an array of those"
|
|
203
|
+
raise ArgumentError, "output_policy must be a policy file path, a skill, a #call/#check object, or an array of those"
|
|
211
204
|
end
|
|
212
205
|
end
|
|
213
206
|
|
|
@@ -223,12 +216,8 @@ module TurnKit
|
|
|
223
216
|
)
|
|
224
217
|
end
|
|
225
218
|
|
|
226
|
-
def normalize_output_policy_mode(
|
|
227
|
-
|
|
228
|
-
raise ArgumentError, "use output_policy_mode: or output_audit_mode:, not both"
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
value = output_policy_mode || output_audit_mode || :report
|
|
219
|
+
def normalize_output_policy_mode(value)
|
|
220
|
+
value ||= :fail
|
|
232
221
|
mode = value.to_sym
|
|
233
222
|
raise ArgumentError, "unknown output_policy_mode: #{value}" unless %i[report fail].include?(mode)
|
|
234
223
|
|
data/lib/turnkit/budget.rb
CHANGED
|
@@ -2,16 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Budget
|
|
5
|
-
attr_reader :root_started_at, :max_iterations, :timeout, :max_depth, :max_tool_executions, :max_tool_executions_by_name, :
|
|
5
|
+
attr_reader :root_started_at, :max_iterations, :timeout, :max_depth, :max_tool_executions, :max_tool_executions_by_name, :max_spend
|
|
6
6
|
|
|
7
|
-
def
|
|
7
|
+
def self.resume(store:, root_turn_id:, limits: {})
|
|
8
|
+
turns = store.list_turns(root_turn_id: root_turn_id)
|
|
9
|
+
root = turns.find { |turn| turn.fetch("id") == root_turn_id } || turns.first || {}
|
|
10
|
+
budget = new(**limits.merge(root_started_at: root["started_at"] || Clock.now))
|
|
11
|
+
budget.seed!(turns: turns, tool_executions: turns.flat_map { |turn| store.list_tool_executions(turn_id: turn.fetch("id")) })
|
|
12
|
+
budget
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(max_iterations:, timeout:, max_depth:, max_tool_executions:, max_tool_executions_by_name: {}, max_spend: nil, root_started_at: Clock.now)
|
|
8
16
|
@root_started_at = root_started_at
|
|
9
17
|
@max_iterations = max_iterations
|
|
10
18
|
@timeout = timeout
|
|
11
19
|
@max_depth = max_depth
|
|
12
20
|
@max_tool_executions = max_tool_executions
|
|
13
21
|
@max_tool_executions_by_name = normalize_tool_limits(max_tool_executions_by_name)
|
|
14
|
-
@
|
|
22
|
+
@max_spend = max_spend
|
|
15
23
|
@iterations = 0
|
|
16
24
|
@tool_executions = 0
|
|
17
25
|
@tool_executions_by_name = Hash.new(0)
|
|
@@ -19,6 +27,17 @@ module TurnKit
|
|
|
19
27
|
@mutex = Mutex.new
|
|
20
28
|
end
|
|
21
29
|
|
|
30
|
+
def seed!(turns:, tool_executions:)
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
@iterations = Array(turns).sum { |turn| (turn["options"] || {})["iterations"].to_i }
|
|
33
|
+
completed = Array(tool_executions).select { |execution| %w[completed failed].include?(execution["status"]) && !execution.dig("error", "details", "budget_denied") }
|
|
34
|
+
@tool_executions = completed.length
|
|
35
|
+
completed.each { |execution| @tool_executions_by_name[execution.fetch("tool_name").to_s] += 1 }
|
|
36
|
+
@cost = Array(turns).sum { |turn| turn["cost"].to_f }
|
|
37
|
+
end
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
22
41
|
def count_iteration!
|
|
23
42
|
@mutex.synchronize do
|
|
24
43
|
raise BudgetError, "maximum iterations reached" if max_iterations && @iterations >= max_iterations
|
|
@@ -44,11 +63,11 @@ module TurnKit
|
|
|
44
63
|
end
|
|
45
64
|
|
|
46
65
|
def add_cost!(cost)
|
|
47
|
-
return unless cost &&
|
|
66
|
+
return unless cost && max_spend
|
|
48
67
|
|
|
49
68
|
@mutex.synchronize do
|
|
50
69
|
@cost += cost.to_f
|
|
51
|
-
raise BudgetError, "cost limit reached" if @cost >
|
|
70
|
+
raise BudgetError, "cost limit reached" if @cost > max_spend
|
|
52
71
|
end
|
|
53
72
|
end
|
|
54
73
|
|
data/lib/turnkit/client.rb
CHANGED
|
@@ -9,5 +9,9 @@ module TurnKit
|
|
|
9
9
|
def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
|
|
10
10
|
raise NotImplementedError
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
def paint(prompt:, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: nil, on_event: nil)
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
12
16
|
end
|
|
13
17
|
end
|
data/lib/turnkit/compaction.rb
CHANGED
|
@@ -83,6 +83,7 @@ module TurnKit
|
|
|
83
83
|
- Keep every section.
|
|
84
84
|
- Use terse bullets.
|
|
85
85
|
- Preserve exact file paths, commands, error strings, IDs, and important values.
|
|
86
|
+
- In Tool Results To Remember, record which skill keys were loaded.
|
|
86
87
|
- Do not invent facts.
|
|
87
88
|
- Do not include secrets.
|
|
88
89
|
- Do not include a greeting or preamble.
|
data/lib/turnkit/error.rb
CHANGED
|
@@ -50,7 +50,6 @@ class CreateTurnkitTables < ActiveRecord::Migration[7.1]
|
|
|
50
50
|
t.string :kind, null: false
|
|
51
51
|
t.integer :sequence, null: false
|
|
52
52
|
t.json :content, null: false, default: []
|
|
53
|
-
t.text :text
|
|
54
53
|
t.string :tool_execution_uid
|
|
55
54
|
t.string :provider_message_id
|
|
56
55
|
t.json :metadata, null: false, default: {}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "open-uri"
|
|
5
|
+
|
|
6
|
+
module TurnKit
|
|
7
|
+
class ImageResult
|
|
8
|
+
attr_reader :url, :data, :mime_type, :revised_prompt, :model, :provider, :usage, :params, :metadata
|
|
9
|
+
|
|
10
|
+
def self.from_h(value)
|
|
11
|
+
new(**value.transform_keys(&:to_sym))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(url: nil, data: nil, mime_type: nil, revised_prompt: nil, model: nil, provider: nil, usage: Usage.new, params: {}, metadata: {}, **)
|
|
15
|
+
@url = url
|
|
16
|
+
@data = data
|
|
17
|
+
@mime_type = mime_type
|
|
18
|
+
@revised_prompt = revised_prompt
|
|
19
|
+
@model = model
|
|
20
|
+
@provider = provider
|
|
21
|
+
@usage = usage.is_a?(Usage) ? usage : Usage.from_h(usage || {})
|
|
22
|
+
@params = params || {}
|
|
23
|
+
@metadata = metadata || {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_blob
|
|
27
|
+
raise Error, "image has no url or data" if url.to_s.empty? && data.to_s.empty?
|
|
28
|
+
|
|
29
|
+
data ? Base64.decode64(data) : URI.open(url, &:read)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def cost
|
|
33
|
+
Cost.from_usage(usage, model: model)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_h
|
|
37
|
+
{
|
|
38
|
+
"url" => url,
|
|
39
|
+
"data" => data,
|
|
40
|
+
"mime_type" => mime_type,
|
|
41
|
+
"revised_prompt" => revised_prompt,
|
|
42
|
+
"model" => model,
|
|
43
|
+
"provider" => provider,
|
|
44
|
+
"usage" => usage.to_h,
|
|
45
|
+
"cost" => cost.to_h,
|
|
46
|
+
"params" => params,
|
|
47
|
+
"metadata" => metadata
|
|
48
|
+
}.compact
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
class ImageTool < Tool
|
|
5
|
+
class << self
|
|
6
|
+
%i[model provider size assume_model_exists params].each do |name|
|
|
7
|
+
define_method(name) do |value = nil|
|
|
8
|
+
instance_variable_set("@#{name}", value) unless value.nil?
|
|
9
|
+
instance_variable_get("@#{name}")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(turnkit_context:, **arguments)
|
|
15
|
+
turnkit_context.turn.paint(
|
|
16
|
+
prompt(**arguments),
|
|
17
|
+
model: self.class.model,
|
|
18
|
+
provider: self.class.provider,
|
|
19
|
+
size: self.class.size,
|
|
20
|
+
assume_model_exists: self.class.assume_model_exists,
|
|
21
|
+
params: self.class.params || {},
|
|
22
|
+
metadata: metadata(**arguments)
|
|
23
|
+
).to_h
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def metadata(**)
|
|
27
|
+
{}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
class LoadSkillTool < Tool
|
|
5
|
+
tool_name "load_skill"
|
|
6
|
+
description "Load the full instructions for an available skill by key."
|
|
7
|
+
parameter :key, :string, required: true, description: "Skill key from <skills_available>."
|
|
8
|
+
|
|
9
|
+
def self.for(skills)
|
|
10
|
+
Class.new(self) do
|
|
11
|
+
tool_name "load_skill"
|
|
12
|
+
@skills = Array(skills).to_h { |skill| [ skill.key, skill ] }
|
|
13
|
+
class << self
|
|
14
|
+
attr_reader :skills
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(key:, context:)
|
|
20
|
+
skill = self.class.skills[key]
|
|
21
|
+
unless skill
|
|
22
|
+
available = self.class.skills.keys.join(", ")
|
|
23
|
+
raise ToolError, "unknown skill: #{key}. Available: #{available}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
{ "key" => skill.key, "name" => skill.name, "content" => skill.content }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/turnkit/memory_store.rb
CHANGED
|
@@ -68,6 +68,17 @@ module TurnKit
|
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
def claim_turn(id, from: "pending", to: "running", **attributes)
|
|
72
|
+
attrs = Record.turn_update(attributes.merge(status: to))
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
record = @turns.fetch(id)
|
|
75
|
+
return nil unless record["status"] == from
|
|
76
|
+
|
|
77
|
+
record.merge!(attrs.merge("updated_at" => Clock.now))
|
|
78
|
+
duplicate(record)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
71
82
|
def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil)
|
|
72
83
|
@mutex.synchronize do
|
|
73
84
|
rows = @turns.values
|
data/lib/turnkit/message.rb
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
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].freeze
|
|
6
|
+
KINDS = %w[text tool_call tool_result context_summary image].freeze
|
|
7
7
|
|
|
8
8
|
attr_reader :id, :conversation_id, :turn_id, :role, :kind, :sequence
|
|
9
|
-
attr_reader :content, :
|
|
9
|
+
attr_reader :content, :tool_execution_id, :provider_message_id, :metadata, :created_at
|
|
10
10
|
|
|
11
11
|
def initialize(attributes = {})
|
|
12
12
|
attrs = stringify(attributes)
|
|
@@ -16,8 +16,7 @@ module TurnKit
|
|
|
16
16
|
@role = attrs.fetch("role").to_s
|
|
17
17
|
@kind = attrs.fetch("kind", "text").to_s
|
|
18
18
|
@sequence = attrs.fetch("sequence").to_i
|
|
19
|
-
@content = normalize_content(attrs["content"]
|
|
20
|
-
@text = attrs["text"] || extract_text(@content)
|
|
19
|
+
@content = normalize_content(attrs["content"].nil? ? attrs["text"] : attrs["content"])
|
|
21
20
|
@tool_execution_id = attrs["tool_execution_id"]
|
|
22
21
|
@provider_message_id = attrs["provider_message_id"]
|
|
23
22
|
@metadata = attrs["metadata"] || {}
|
|
@@ -35,7 +34,6 @@ module TurnKit
|
|
|
35
34
|
"kind" => kind,
|
|
36
35
|
"sequence" => sequence,
|
|
37
36
|
"content" => content,
|
|
38
|
-
"text" => text,
|
|
39
37
|
"tool_execution_id" => tool_execution_id,
|
|
40
38
|
"provider_message_id" => provider_message_id,
|
|
41
39
|
"metadata" => metadata,
|
|
@@ -59,6 +57,17 @@ module TurnKit
|
|
|
59
57
|
kind == "context_summary"
|
|
60
58
|
end
|
|
61
59
|
|
|
60
|
+
def image?
|
|
61
|
+
kind == "image"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def text
|
|
65
|
+
content.filter_map do |part|
|
|
66
|
+
attrs = stringify(part)
|
|
67
|
+
attrs["text"] if attrs["type"] == "text"
|
|
68
|
+
end.join("\n")
|
|
69
|
+
end
|
|
70
|
+
|
|
62
71
|
def compaction_metadata
|
|
63
72
|
metadata.fetch("compaction", {})
|
|
64
73
|
end
|
|
@@ -69,13 +78,15 @@ module TurnKit
|
|
|
69
78
|
end
|
|
70
79
|
|
|
71
80
|
def normalize_content(value)
|
|
72
|
-
return value if value.is_a?(Array)
|
|
81
|
+
return Array(value).map { |part| normalize_part(part) } if value.is_a?(Array)
|
|
73
82
|
|
|
74
83
|
[ { "type" => "text", "text" => value.to_s } ]
|
|
75
84
|
end
|
|
76
85
|
|
|
77
|
-
def
|
|
78
|
-
|
|
86
|
+
def normalize_part(part)
|
|
87
|
+
attrs = part.respond_to?(:to_h) ? part.to_h.transform_keys(&:to_s) : { "type" => "text", "text" => part.to_s }
|
|
88
|
+
attrs["type"] ||= "text"
|
|
89
|
+
attrs
|
|
79
90
|
end
|
|
80
91
|
|
|
81
92
|
def validate!
|
|
@@ -40,9 +40,12 @@ module TurnKit
|
|
|
40
40
|
def to_h
|
|
41
41
|
case message.kind
|
|
42
42
|
when "tool_call"
|
|
43
|
-
{ role: :assistant, content:
|
|
43
|
+
{ role: :assistant, content: projected_content, tool_calls: tool_call_parts }
|
|
44
44
|
when "tool_result"
|
|
45
|
-
|
|
45
|
+
part = message.content.find { |candidate| candidate.fetch("type") == "tool_result" }
|
|
46
|
+
{ role: :tool, content: part&.fetch("text", message.text) || message.text, tool_call_id: part&.fetch("tool_call_id", nil) }
|
|
47
|
+
when "image"
|
|
48
|
+
{ role: :assistant, content: projected_images }
|
|
46
49
|
else
|
|
47
50
|
{ role: message.role.to_sym, content: message.text }
|
|
48
51
|
end
|
|
@@ -50,5 +53,28 @@ module TurnKit
|
|
|
50
53
|
|
|
51
54
|
private
|
|
52
55
|
attr_reader :message
|
|
56
|
+
|
|
57
|
+
def projected_content
|
|
58
|
+
parts = message.content.reject { |part| %w[tool_call provider].include?(part.fetch("type")) }
|
|
59
|
+
ordered = parts.select { |part| part.fetch("type") == "thinking" } + parts.select { |part| part.fetch("type") == "text" }
|
|
60
|
+
ordered.filter_map { |part| part.fetch("text", nil) }.join("\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tool_call_parts
|
|
64
|
+
message.content.filter_map do |part|
|
|
65
|
+
next unless part.fetch("type") == "tool_call"
|
|
66
|
+
|
|
67
|
+
{ "id" => part.fetch("id"), "name" => part.fetch("name"), "arguments" => part["arguments"] || {} }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def projected_images
|
|
72
|
+
message.content.filter_map do |part|
|
|
73
|
+
next unless part.fetch("type") == "image"
|
|
74
|
+
|
|
75
|
+
attrs = part.slice("url", "mime_type", "model", "provider", "revised_prompt").compact
|
|
76
|
+
"Generated image: #{attrs.to_json}"
|
|
77
|
+
end.join("\n")
|
|
78
|
+
end
|
|
53
79
|
end
|
|
54
80
|
end
|
|
@@ -27,6 +27,19 @@ module TurnKit
|
|
|
27
27
|
new(name: name || File.basename(path, File.extname(path)), content: File.read(path), **options)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def self.from_skill(skill, **options)
|
|
31
|
+
new(name: skill.key, content: skill.content, **options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.require_image
|
|
35
|
+
lambda do |output, output_data: nil, turn: nil, **|
|
|
36
|
+
data = output_data.is_a?(Hash) ? output_data : output
|
|
37
|
+
images = data.is_a?(Hash) ? data["images"] || data[:images] : nil
|
|
38
|
+
has_image = Array(images).any? || turn&.conversation&.messages_for_turn(turn)&.any?(&:image?)
|
|
39
|
+
{ rule: "image_required", message: "output must include an image result" } unless has_image
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
30
43
|
def initialize(content:, name: "output_policy", model: nil, thinking: nil, client: nil)
|
|
31
44
|
@name = name.to_s
|
|
32
45
|
@content = content.to_s
|
|
@@ -78,6 +91,8 @@ module TurnKit
|
|
|
78
91
|
|
|
79
92
|
Set approved to true only when the output satisfies the policy. For each violation, include a concise rule and message. Do not repair the output. Do not wrap the JSON in Markdown. Do not include commentary before or after the JSON.
|
|
80
93
|
|
|
94
|
+
The policy may be a skill; treat its output-facing rules as normative and ignore process steps that are not observable in the output.
|
|
95
|
+
|
|
81
96
|
Policy:
|
|
82
97
|
#{content}
|
|
83
98
|
TEXT
|
data/lib/turnkit/result.rb
CHANGED
|
@@ -2,19 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Result
|
|
5
|
-
attr_reader :
|
|
5
|
+
attr_reader :parts, :usage, :model, :finish_reason, :output_data
|
|
6
6
|
|
|
7
|
-
def initialize(text: "", tool_calls: [], usage: Usage.new, model: nil, finish_reason: nil, output_data: nil)
|
|
8
|
-
@
|
|
9
|
-
@tool_calls = Array(tool_calls)
|
|
7
|
+
def initialize(text: "", tool_calls: [], parts: nil, usage: Usage.new, model: nil, finish_reason: nil, output_data: nil)
|
|
8
|
+
@parts = parts ? normalize_parts(parts) : synthesize_parts(text: text, tool_calls: tool_calls)
|
|
10
9
|
@usage = usage || Usage.new
|
|
11
10
|
@model = model
|
|
12
11
|
@finish_reason = finish_reason
|
|
13
12
|
@output_data = output_data
|
|
14
13
|
end
|
|
15
14
|
|
|
15
|
+
def text
|
|
16
|
+
parts.filter_map { |part| part["text"] if part["type"] == "text" }.join("\n")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def tool_calls
|
|
20
|
+
parts.filter_map do |part|
|
|
21
|
+
next unless part["type"] == "tool_call"
|
|
22
|
+
|
|
23
|
+
ToolCall.new(id: part.fetch("id"), name: part.fetch("name"), arguments: part["arguments"] || {}, arguments_error: part["arguments_error"])
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
16
27
|
def tool_calls?
|
|
17
28
|
tool_calls.any?
|
|
18
29
|
end
|
|
30
|
+
|
|
31
|
+
def images
|
|
32
|
+
parts.filter_map do |part|
|
|
33
|
+
next unless part["type"] == "image"
|
|
34
|
+
|
|
35
|
+
ImageResult.from_h(part)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def image?
|
|
40
|
+
images.any?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
def synthesize_parts(text:, tool_calls:)
|
|
45
|
+
parts = []
|
|
46
|
+
parts << { "type" => "text", "text" => text.to_s } unless text.to_s.empty?
|
|
47
|
+
Array(tool_calls).each do |call|
|
|
48
|
+
parts << { "type" => "tool_call", "id" => call.id, "name" => call.name, "arguments" => call.arguments, "arguments_error" => call.arguments_error }.compact
|
|
49
|
+
end
|
|
50
|
+
parts
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def normalize_parts(value)
|
|
54
|
+
Array(value).map { |part| part.to_h.transform_keys(&:to_s) }
|
|
55
|
+
end
|
|
19
56
|
end
|
|
20
57
|
end
|
data/lib/turnkit/run.rb
CHANGED
|
@@ -14,8 +14,8 @@ module TurnKit
|
|
|
14
14
|
def output = output_text
|
|
15
15
|
def output_text = turn.output_text
|
|
16
16
|
def output_data = turn.output_data
|
|
17
|
-
def
|
|
18
|
-
def
|
|
17
|
+
def policy_audit = turn.policy_audit
|
|
18
|
+
def policy_clean? = policy_audit.nil? || policy_audit.fetch("clean", false)
|
|
19
19
|
def usage = Usage.from_records(turn_records)
|
|
20
20
|
def cost = Cost.from_records(turn_records)
|
|
21
21
|
def steps = turn_records.length
|
|
@@ -27,9 +27,8 @@ module TurnKit
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def messages
|
|
30
|
-
turn_records.flat_map do |
|
|
31
|
-
|
|
32
|
-
turn.store.list_messages(conversation.fetch("id"))
|
|
30
|
+
turn_records.map { |record| record.fetch("conversation_id") }.uniq.flat_map do |conversation_id|
|
|
31
|
+
turn.store.list_messages(conversation_id).map { |attrs| Message.new(attrs) }
|
|
33
32
|
end
|
|
34
33
|
end
|
|
35
34
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
module SchemaCheck
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def validate!(value, schema, error_class: ToolValidationError, label: "input")
|
|
8
|
+
schema = stringify_schema(schema || {})
|
|
9
|
+
type = schema["type"] || "object"
|
|
10
|
+
validate_type!(value, type, schema, error_class: error_class, label: label)
|
|
11
|
+
validate_enum!(value, schema, error_class: error_class, label: label)
|
|
12
|
+
|
|
13
|
+
if type == "object" && schema["properties"]
|
|
14
|
+
attrs = value.respond_to?(:to_h) ? value.to_h.transform_keys(&:to_s) : {}
|
|
15
|
+
required = Array(schema["required"]).map(&:to_s)
|
|
16
|
+
missing = required.reject { |name| attrs.key?(name) }
|
|
17
|
+
raise error_class, "#{label} missing required field#{missing.length == 1 ? "" : "s"}: #{missing.join(", ")}" if missing.any?
|
|
18
|
+
|
|
19
|
+
schema.fetch("properties", {}).each do |name, child_schema|
|
|
20
|
+
next unless attrs.key?(name)
|
|
21
|
+
|
|
22
|
+
validate!(attrs[name], child_schema, error_class: error_class, label: "#{label}.#{name}")
|
|
23
|
+
end
|
|
24
|
+
elsif type == "array" && schema["items"] && value.is_a?(Array)
|
|
25
|
+
value.each_with_index do |item, index|
|
|
26
|
+
validate!(item, schema["items"], error_class: error_class, label: "#{label}[#{index}]")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stringify_schema(value)
|
|
34
|
+
case value
|
|
35
|
+
when Hash
|
|
36
|
+
value.transform_keys(&:to_s).transform_values { |nested| stringify_schema(nested) }
|
|
37
|
+
when Array
|
|
38
|
+
value.map { |nested| stringify_schema(nested) }
|
|
39
|
+
when Symbol
|
|
40
|
+
value.to_s
|
|
41
|
+
else
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_type!(value, type, schema, error_class:, label:)
|
|
47
|
+
return if value.nil? && !Array(schema["required"]).include?(label.to_s)
|
|
48
|
+
|
|
49
|
+
valid = case type.to_s
|
|
50
|
+
when "string" then value.is_a?(String)
|
|
51
|
+
when "integer" then value.is_a?(Integer)
|
|
52
|
+
when "number" then value.is_a?(Numeric)
|
|
53
|
+
when "boolean" then value == true || value == false
|
|
54
|
+
when "array" then value.is_a?(Array)
|
|
55
|
+
when "object" then value.is_a?(Hash)
|
|
56
|
+
else true
|
|
57
|
+
end
|
|
58
|
+
raise error_class, "#{label} must be a #{type}" unless valid
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_enum!(value, schema, error_class:, label:)
|
|
62
|
+
enum = schema["enum"]
|
|
63
|
+
return unless enum && !Array(enum).include?(value)
|
|
64
|
+
|
|
65
|
+
raise error_class, "#{label} must be one of: #{Array(enum).join(", ")}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|