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.
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, :cost_limit, :max_depth, :max_tool_executions, :max_tool_executions_by_name
7
- attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :on_event
8
- attr_reader :output_audit, :output_audit_mode, :output_policy_model
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, cost_limit: nil, max_depth: nil, max_tool_executions: nil, max_tool_executions_by_name: nil, thinking: nil, compaction: nil,
13
- output_schema: nil, output_audit: nil, output_audit_mode: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, on_event: 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
- @cost_limit = cost_limit
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
- @output_audit = normalize_output_policy_options(output_audit: output_audit, output_policy: output_policy, output_policy_model: output_policy_model, output_policy_thinking: output_policy_thinking)
38
- @output_audit_mode = normalize_output_policy_mode(output_audit_mode: output_audit_mode, output_policy_mode: output_policy_mode)
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 effective_output_audit
103
- Array(output_audit).compact
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
- cost_limit: cost_limit || TurnKit.cost_limit,
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(output_audit_mode:, output_policy_mode:)
227
- if output_audit_mode && output_policy_mode && output_audit_mode.to_sym != output_policy_mode.to_sym
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
 
@@ -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, :cost_limit
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 initialize(max_iterations:, timeout:, max_depth:, max_tool_executions:, max_tool_executions_by_name: {}, cost_limit: nil, root_started_at: Clock.now)
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
- @cost_limit = cost_limit
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 && cost_limit
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 > cost_limit
70
+ raise BudgetError, "cost limit reached" if @cost > max_spend
52
71
  end
53
72
  end
54
73
 
@@ -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
@@ -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
@@ -5,6 +5,7 @@ module TurnKit
5
5
  class BudgetError < Error; end
6
6
  class ConfigError < Error; end
7
7
  class CompactionError < Error; end
8
+ class InputError < Error; end
8
9
  class ModelAccessError < ConfigError; end
9
10
  class StoreError < Error; end
10
11
  class ToolError < Error; end
@@ -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
@@ -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
@@ -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, :text, :tool_execution_id, :provider_message_id, :metadata, :created_at
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"] || attrs["text"])
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 extract_text(blocks)
78
- Array(blocks).filter_map { |block| block.is_a?(Hash) ? block["text"] || block[:text] : nil }.join("\n")
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: message.text, tool_calls: message.metadata.fetch("tool_calls", []) }
43
+ { role: :assistant, content: projected_content, tool_calls: tool_call_parts }
44
44
  when "tool_result"
45
- { role: :tool, content: message.text, tool_call_id: message.metadata["tool_call_id"] }
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
@@ -2,19 +2,56 @@
2
2
 
3
3
  module TurnKit
4
4
  class Result
5
- attr_reader :text, :tool_calls, :usage, :model, :finish_reason, :output_data
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
- @text = text.to_s
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 output_audit = turn.output_audit
18
- def output_audit_clean? = output_audit.nil? || output_audit.fetch("clean", false)
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 |record|
31
- conversation = turn.store.load_conversation(record.fetch("conversation_id"))
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