turnkit 0.2.6 → 0.2.7

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.
@@ -3,7 +3,28 @@
3
3
  module TurnKit
4
4
  module Adapters
5
5
  class RubyLLM < Client
6
- def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, metadata: nil)
6
+ KEY_BY_PROVIDER = {
7
+ openai: "OPENAI_API_KEY",
8
+ gemini: "GEMINI_API_KEY",
9
+ anthropic: "ANTHROPIC_API_KEY",
10
+ openrouter: "OPENROUTER_API_KEY"
11
+ }.freeze
12
+
13
+ def validate!(model:)
14
+ require "ruby_llm"
15
+
16
+ raise ModelAccessError, "model is required" if model.to_s.empty?
17
+
18
+ configure_from_environment
19
+ provider = provider_for(model)
20
+ key_name = KEY_BY_PROVIDER[provider]
21
+ return true unless key_name
22
+ return true if ENV[key_name].to_s != "" || config_key_present?(provider)
23
+
24
+ raise ModelAccessError, "#{key_name} is required for #{model}. Set ENV[#{key_name.inspect}] or configure RubyLLM before running TurnKit."
25
+ end
26
+
27
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
7
28
  require "ruby_llm"
8
29
 
9
30
  configure_from_environment
@@ -12,6 +33,7 @@ module TurnKit
12
33
  add_instructions(chat, instructions, model: model)
13
34
  chat.with_temperature(temperature) if temperature
14
35
  apply_thinking(chat, thinking)
36
+ chat.with_schema(normalize_schema(output_schema)) if output_schema
15
37
  Array(tools).each { |tool| chat.with_tool(ruby_llm_tool(tool)) }
16
38
  Array(messages).each { |message| add_message(chat, message) }
17
39
 
@@ -28,11 +50,39 @@ module TurnKit
28
50
  config.openrouter_api_key ||= ENV["OPENROUTER_API_KEY"]
29
51
  end
30
52
 
53
+ def provider_for(model)
54
+ value = model.to_s.downcase
55
+ return :openrouter if value.start_with?("openrouter/")
56
+ return :anthropic if value.start_with?("anthropic/", "claude")
57
+ return :gemini if value.start_with?("gemini/", "gemini")
58
+ return :openai if value.start_with?("openai/", "gpt", "o1", "o3", "o4")
59
+
60
+ nil
61
+ end
62
+
63
+ def config_key_present?(provider)
64
+ value = ::RubyLLM.config.public_send("#{provider}_api_key") if ::RubyLLM.config.respond_to?("#{provider}_api_key")
65
+ value.to_s != ""
66
+ end
67
+
31
68
  def apply_thinking(chat, thinking)
32
69
  thinking = Agent.normalize_thinking(thinking)
33
70
  chat.with_thinking(**thinking) if thinking
34
71
  end
35
72
 
73
+ def normalize_schema(schema)
74
+ case schema
75
+ when Hash
76
+ normalized = schema.transform_keys(&:to_s).transform_values { |value| normalize_schema(value) }
77
+ normalized["additionalProperties"] = false if normalized["type"] == "object" && !normalized.key?("additionalProperties")
78
+ normalized
79
+ when Array
80
+ schema.map { |value| normalize_schema(value) }
81
+ else
82
+ schema
83
+ end
84
+ end
85
+
36
86
  def complete_without_tool_execution(chat)
37
87
  provider = chat.instance_variable_get(:@provider)
38
88
  provider.complete(
@@ -110,9 +160,7 @@ module TurnKit
110
160
  Class.new(::RubyLLM::Tool) do
111
161
  define_singleton_method(:name) { tool.tool_name }
112
162
  description tool.description
113
- tool.parameters.each do |param|
114
- param(param.fetch(:name).to_sym, type: param.fetch(:type), required: param.fetch(:required), desc: param.fetch(:description))
115
- end
163
+ params tool.input_schema
116
164
 
117
165
  define_method(:execute) do |**arguments|
118
166
  raise ToolError, "tools must be executed by TurnKit turns, not the RubyLLM adapter"
@@ -133,13 +181,29 @@ module TurnKit
133
181
  cost: response_cost(response)
134
182
  )
135
183
  Result.new(
136
- text: response.respond_to?(:content) ? response.content.to_s : response.to_s,
184
+ text: response_text(response),
185
+ output_data: response_data(response),
137
186
  tool_calls: tool_calls,
138
187
  usage: usage,
139
188
  model: response.respond_to?(:model_id) ? response.model_id : model
140
189
  )
141
190
  end
142
191
 
192
+ def response_text(response)
193
+ content = response.respond_to?(:content) ? response.content : response
194
+ content.is_a?(Hash) || content.is_a?(Array) ? content.to_json : content.to_s
195
+ end
196
+
197
+ def response_data(response)
198
+ content = response.respond_to?(:content) ? response.content : nil
199
+ return content if content.is_a?(Hash) || content.is_a?(Array)
200
+ return nil unless content.is_a?(String)
201
+
202
+ JSON.parse(content)
203
+ rescue JSON::ParserError
204
+ nil
205
+ end
206
+
143
207
  def token_value(response, method)
144
208
  response.respond_to?(method) ? response.public_send(method).to_i : 0
145
209
  end
data/lib/turnkit/agent.rb CHANGED
@@ -4,11 +4,12 @@ module TurnKit
4
4
  class Agent
5
5
  attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
6
6
  attr_reader :client, :store, :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions
7
- attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction
7
+ attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :on_event
8
8
 
9
9
  def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], available_skills: [], sub_agents: [],
10
10
  system_prompt: nil, prompt_sections: nil, prompt_mode: nil, client: nil, store: nil,
11
- max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil, thinking: nil, compaction: nil)
11
+ max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil, thinking: nil, compaction: nil,
12
+ output_schema: nil, on_event: nil)
12
13
  @name = name.to_s
13
14
  @description = description.to_s
14
15
  @model = model
@@ -29,7 +30,10 @@ module TurnKit
29
30
  @max_tool_executions = max_tool_executions
30
31
  @thinking = self.class.normalize_thinking(thinking)
31
32
  @compaction = compaction
33
+ @output_schema = output_schema
34
+ @on_event = on_event
32
35
  raise ArgumentError, "name is required" if @name.empty?
36
+ validate_tools!
33
37
  end
34
38
 
35
39
  def self.normalize_thinking(value)
@@ -86,6 +90,10 @@ module TurnKit
86
90
  tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
87
91
  end
88
92
 
93
+ def effective_on_event
94
+ on_event || TurnKit.on_event
95
+ end
96
+
89
97
  def effective_available_skills
90
98
  (Array(TurnKit.available_skills) + available_skills).uniq { |skill| skill.key }
91
99
  end
@@ -129,5 +137,14 @@ module TurnKit
129
137
  parts << SystemPrompt.loaded_skills_text(skills)
130
138
  parts.reject(&:empty?).join("\n\n")
131
139
  end
140
+
141
+ private
142
+ def validate_tools!
143
+ names = effective_tools.map(&:tool_name)
144
+ duplicate = names.find { |name| names.count(name) > 1 }
145
+ raise ArgumentError, "duplicate tool name: #{duplicate}" if duplicate
146
+
147
+ effective_tools.each(&:validate_definition!)
148
+ end
132
149
  end
133
150
  end
@@ -2,7 +2,11 @@
2
2
 
3
3
  module TurnKit
4
4
  class Client
5
- def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, metadata: nil)
5
+ def validate!(model:)
6
+ true
7
+ end
8
+
9
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, thinking: nil, output_schema: nil, metadata: nil, on_event: nil)
6
10
  raise NotImplementedError
7
11
  end
8
12
  end
@@ -26,16 +26,17 @@ module TurnKit
26
26
  async ? turn : turn.run!
27
27
  end
28
28
 
29
- def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil)
30
- build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, depth: depth, agent: agent, thinking: thinking, compact: compact).run!
29
+ def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, on_event: nil)
30
+ build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, depth: depth, agent: agent, thinking: thinking, compact: compact, output_schema: output_schema, on_event: on_event).run!
31
31
  end
32
32
 
33
- def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil)
33
+ def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, on_event: nil)
34
34
  snapshot = latest_message_sequence
35
35
  effective_thinking = thinking.equal?(THINKING_UNSET) ? agent.effective_thinking : Agent.normalize_thinking(thinking)
36
36
  options = { "trigger_message_id" => trigger_message_id }.compact
37
37
  options["thinking"] = effective_thinking
38
38
  options["compact"] = compact unless compact.nil?
39
+ options["output_schema"] = output_schema || agent.output_schema if output_schema || agent.output_schema
39
40
  record = store.create_turn(
40
41
  "conversation_id" => id,
41
42
  "agent_name" => agent.name,
@@ -47,7 +48,7 @@ module TurnKit
47
48
  "model" => model || self.model || agent.effective_model,
48
49
  "options" => options
49
50
  )
50
- Turn.new(agent: agent, conversation: self, record: record, store: store, budget: budget, depth: depth)
51
+ Turn.new(agent: agent, conversation: self, record: record, store: store, budget: budget, depth: depth, on_event: on_event)
51
52
  end
52
53
 
53
54
  def compact!(focus: nil, model: nil)
data/lib/turnkit/error.rb CHANGED
@@ -4,6 +4,8 @@ module TurnKit
4
4
  class Error < StandardError; end
5
5
  class ConfigError < Error; end
6
6
  class CompactionError < Error; end
7
+ class ModelAccessError < ConfigError; end
7
8
  class StoreError < Error; end
8
9
  class ToolError < Error; end
10
+ class ToolValidationError < ToolError; end
9
11
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Event
5
+ attr_reader :type, :turn_id, :conversation_id, :payload, :created_at
6
+
7
+ def initialize(type:, turn_id:, conversation_id:, payload: {}, created_at: Clock.now)
8
+ @type = type.to_s
9
+ @turn_id = turn_id
10
+ @conversation_id = conversation_id
11
+ @payload = payload || {}
12
+ @created_at = created_at
13
+ end
14
+
15
+ def to_h
16
+ {
17
+ "type" => type,
18
+ "turn_id" => turn_id,
19
+ "conversation_id" => conversation_id,
20
+ "payload" => payload,
21
+ "created_at" => created_at
22
+ }
23
+ end
24
+ end
25
+ end
@@ -30,6 +30,7 @@ class CreateTurnkitTables < ActiveRecord::Migration[7.1]
30
30
  t.decimal :cost, precision: 14, scale: 6
31
31
  t.json :error
32
32
  t.text :output_text
33
+ t.json :output_data
33
34
  t.datetime :started_at
34
35
  t.datetime :heartbeat_at
35
36
  t.datetime :completed_at
@@ -12,8 +12,14 @@ TurnKit.tool_execution_record_class = "Turnkit::ToolExecution"
12
12
  # TurnKit.timeout = 300
13
13
  # TurnKit.max_depth = 3
14
14
  # TurnKit.max_tool_executions = 100
15
+ # TurnKit.on_event = ->(event) { Rails.logger.info("turnkit.#{event.type} #{event.payload.inspect}") }
15
16
 
16
17
  # TurnKit builds each system prompt from these sections by default.
17
18
  # TurnKit.prompt_sections = %i[agent instructions behavior loaded_skills available_skills tools subject environment]
18
19
  # TurnKit.prompt_behavior = "Custom behavior instructions."
19
20
  # TurnKit.available_skills = TurnKit::Skill.from_directory(Rails.root.join("app/ai/skills"))
21
+
22
+ # Suggested Rails convention:
23
+ # - app/ai/agents/* builds TurnKit::Agent objects for your workflows.
24
+ # - app/ai/tools/* defines TurnKit::Tool subclasses.
25
+ # - app/ai/skills/* stores reusable Markdown skill files.
@@ -25,6 +25,12 @@ module TurnKit
25
25
  template "tool_execution.rb", "app/models/turnkit/tool_execution.rb"
26
26
  end
27
27
 
28
+ def create_ai_directories
29
+ empty_directory "app/ai/agents"
30
+ empty_directory "app/ai/tools"
31
+ empty_directory "app/ai/skills"
32
+ end
33
+
28
34
  def copy_migration
29
35
  migration_template "create_turnkit_tables.rb", "db/migrate/create_turnkit_tables.rb"
30
36
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class ModelRequest
5
+ attr_reader :model, :messages, :tools, :instructions, :thinking, :output_schema, :metadata, :report
6
+
7
+ def initialize(model:, messages:, tools:, instructions:, thinking: nil, output_schema: nil, metadata: {}, report: nil)
8
+ @model = model
9
+ @messages = Array(messages)
10
+ @tools = Array(tools)
11
+ @instructions = instructions.to_s
12
+ @thinking = thinking
13
+ @output_schema = output_schema
14
+ @metadata = metadata || {}
15
+ @report = report || {}
16
+ end
17
+
18
+ def tool_names
19
+ tools.map(&:tool_name)
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ "model" => model,
25
+ "messages" => messages,
26
+ "tools" => tool_names,
27
+ "instructions" => instructions,
28
+ "thinking" => thinking,
29
+ "output_schema" => output_schema,
30
+ "metadata" => metadata,
31
+ "report" => report
32
+ }
33
+ end
34
+ end
35
+ end
@@ -5,7 +5,7 @@ module TurnKit
5
5
  TURN_STATUSES = %w[pending running completed failed cancelled stale].freeze
6
6
  TOOL_EXECUTION_STATUSES = %w[pending running completed failed cancelled].freeze
7
7
 
8
- TURN_UPDATE_KEYS = %w[status options usage cost error output_text started_at heartbeat_at completed_at].freeze
8
+ TURN_UPDATE_KEYS = %w[status options usage cost error output_text output_data started_at heartbeat_at completed_at].freeze
9
9
  TOOL_EXECUTION_UPDATE_KEYS = %w[status result error started_at completed_at].freeze
10
10
 
11
11
  module_function
@@ -49,6 +49,7 @@ module TurnKit
49
49
  "cost" => attrs["cost"],
50
50
  "error" => attrs["error"],
51
51
  "output_text" => attrs["output_text"],
52
+ "output_data" => attrs["output_data"],
52
53
  "started_at" => attrs["started_at"],
53
54
  "heartbeat_at" => attrs["heartbeat_at"],
54
55
  "completed_at" => attrs["completed_at"],
@@ -2,14 +2,15 @@
2
2
 
3
3
  module TurnKit
4
4
  class Result
5
- attr_reader :text, :tool_calls, :usage, :model, :finish_reason
5
+ attr_reader :text, :tool_calls, :usage, :model, :finish_reason, :output_data
6
6
 
7
- def initialize(text: "", tool_calls: [], usage: Usage.new, model: nil, finish_reason: nil)
7
+ def initialize(text: "", tool_calls: [], usage: Usage.new, model: nil, finish_reason: nil, output_data: nil)
8
8
  @text = text.to_s
9
9
  @tool_calls = Array(tool_calls)
10
10
  @usage = usage || Usage.new
11
11
  @model = model
12
12
  @finish_reason = finish_reason
13
+ @output_data = output_data
13
14
  end
14
15
 
15
16
  def tool_calls?
@@ -57,7 +57,7 @@ module TurnKit
57
57
 
58
58
  def create_turn(attributes)
59
59
  attrs = Record.turn(attributes)
60
- record = turn_class.create!(
60
+ record_attrs = {
61
61
  uid: attrs.fetch("id"),
62
62
  conversation_uid: attrs.fetch("conversation_id"),
63
63
  agent_name: attrs["agent_name"],
@@ -75,7 +75,9 @@ module TurnKit
75
75
  started_at: attrs["started_at"],
76
76
  heartbeat_at: attrs["heartbeat_at"],
77
77
  completed_at: attrs["completed_at"]
78
- )
78
+ }
79
+ record_attrs[:output_data] = attrs["output_data"] if turn_has_attribute?("output_data")
80
+ record = turn_class.create!(record_attrs)
79
81
  turn_hash(record)
80
82
  end
81
83
 
@@ -85,7 +87,9 @@ module TurnKit
85
87
 
86
88
  def update_turn(id, attributes)
87
89
  record = turn_class.find_by!(uid: id)
88
- record.update!(Record.turn_update(attributes))
90
+ attrs = Record.turn_update(attributes)
91
+ attrs.delete("output_data") unless turn_has_attribute?("output_data")
92
+ record.update!(attrs)
89
93
  turn_hash(record)
90
94
  end
91
95
 
@@ -160,7 +164,7 @@ module TurnKit
160
164
  end
161
165
 
162
166
  def turn_hash(record)
163
- {
167
+ attrs = {
164
168
  "id" => record.uid, "conversation_id" => record.conversation_uid, "agent_name" => record.agent_name,
165
169
  "parent_turn_id" => record.parent_turn_uid, "parent_tool_execution_id" => record.parent_tool_execution_uid,
166
170
  "root_turn_id" => record.root_turn_uid, "context_message_sequence" => record.context_message_sequence,
@@ -169,6 +173,12 @@ module TurnKit
169
173
  "started_at" => record.started_at, "heartbeat_at" => record.heartbeat_at, "completed_at" => record.completed_at,
170
174
  "created_at" => record.created_at, "updated_at" => record.updated_at
171
175
  }
176
+ attrs["output_data"] = record.output_data if record.respond_to?(:output_data)
177
+ attrs
178
+ end
179
+
180
+ def turn_has_attribute?(name)
181
+ turn_class.respond_to?(:attribute_names) && turn_class.attribute_names.include?(name)
172
182
  end
173
183
 
174
184
  def message_hash(record)
@@ -21,9 +21,17 @@ module TurnKit
21
21
  def call(task:, context: nil, turnkit_context:)
22
22
  sub_agent = self.class.agent
23
23
  parent_turn = turnkit_context.turn
24
- conversation = parent_turn.conversation
25
24
  prompt = [ task, context ].compact.join("\n\n")
26
- trigger = conversation.append_message(role: "user", kind: "text", text: prompt, turn_id: parent_turn.id)
25
+ conversation = sub_agent.conversation(metadata: {
26
+ "parent_conversation_id" => parent_turn.conversation.id,
27
+ "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
+ })
27
35
  child = conversation.run!(
28
36
  trigger_message_id: trigger.id,
29
37
  budget: parent_turn.budget,
@@ -31,9 +39,10 @@ module TurnKit
31
39
  parent_tool_execution: turnkit_context.execution,
32
40
  depth: parent_turn.depth + 1,
33
41
  model: sub_agent.effective_model,
34
- agent: sub_agent
42
+ agent: sub_agent,
43
+ on_event: parent_turn.agent.effective_on_event
35
44
  )
36
- { "turn_id" => child.id, "status" => child.status, "result" => child.output_text }
45
+ { "conversation_id" => conversation.id, "turn_id" => child.id, "status" => child.status, "result" => child.output_text, "output_data" => child.output_data }.compact
37
46
  end
38
47
  end
39
48
  end
data/lib/turnkit/tool.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  module TurnKit
4
4
  class Tool
5
5
  TYPES = %i[string integer number boolean array object enum].freeze
6
+ NAME_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
6
7
 
7
8
  class << self
8
9
  def tool_name(value = nil)
@@ -20,16 +21,22 @@ module TurnKit
20
21
  @usage_hint.to_s
21
22
  end
22
23
 
23
- def parameter(name, type = :string, required: false, description: "", default: nil, enum: nil)
24
+ def parameter(name, type = :string, required: false, description: "", default: nil, enum: nil, items: nil, properties: nil)
25
+ name = name.to_s
24
26
  raise ArgumentError, "unknown parameter type: #{type}" unless TYPES.include?(type)
27
+ raise ArgumentError, "invalid parameter name: #{name}" unless NAME_PATTERN.match?(name)
28
+ raise ArgumentError, "duplicate parameter: #{name}" if parameters.any? { |param| param.fetch(:name) == name }
29
+ raise ArgumentError, "enum values are required for enum parameter: #{name}" if type == :enum && Array(enum).empty?
25
30
 
26
31
  parameters << {
27
- name: name.to_s,
32
+ name: name,
28
33
  type: type,
29
34
  required: required ? true : false,
30
35
  description: description.to_s,
31
36
  default: default,
32
- enum: enum
37
+ enum: enum,
38
+ items: items,
39
+ properties: properties
33
40
  }.compact
34
41
  end
35
42
 
@@ -45,8 +52,56 @@ module TurnKit
45
52
  nil
46
53
  end
47
54
 
55
+ def validate_definition!
56
+ raise ArgumentError, "tool name is required" if tool_name.empty?
57
+ raise ArgumentError, "invalid tool name: #{tool_name}" unless NAME_PATTERN.match?(tool_name)
58
+
59
+ parameters.each do |param|
60
+ type = param.fetch(:type)
61
+ raise ArgumentError, "unknown parameter type: #{type}" unless TYPES.include?(type)
62
+ raise ArgumentError, "enum values are required for enum parameter: #{param.fetch(:name)}" if type == :enum && Array(param[:enum]).empty?
63
+ validate_value!(param[:default], param) if param.key?(:default)
64
+ end
65
+ true
66
+ end
67
+
68
+ def input_schema
69
+ properties = parameters.to_h { |param| [ param.fetch(:name), schema_for(param) ] }
70
+ required = parameters.select { |param| param.fetch(:required) }.map { |param| param.fetch(:name) }
71
+ {
72
+ "type" => "object",
73
+ "properties" => properties,
74
+ "required" => required
75
+ }
76
+ end
77
+
78
+ def validate_arguments(arguments)
79
+ attrs = arguments.respond_to?(:to_h) ? arguments.to_h.transform_keys(&:to_s) : {}
80
+ allowed = parameters.map { |param| param.fetch(:name) }
81
+ unknown = attrs.keys - allowed
82
+ raise ToolValidationError, "unknown argument#{unknown.length == 1 ? "" : "s"}: #{unknown.join(", ")}" if unknown.any?
83
+
84
+ normalized = {}
85
+ parameters.each do |param|
86
+ name = param.fetch(:name)
87
+ if attrs.key?(name)
88
+ value = attrs[name]
89
+ elsif param.key?(:default)
90
+ value = param[:default]
91
+ elsif param.fetch(:required)
92
+ raise ToolValidationError, "missing required argument: #{name}"
93
+ else
94
+ next
95
+ end
96
+
97
+ validate_value!(value, param)
98
+ normalized[name] = value
99
+ end
100
+ normalized
101
+ end
102
+
48
103
  def call(arguments = {}, context:)
49
- keyword_arguments = symbolize(arguments)
104
+ keyword_arguments = symbolize(validate_arguments(arguments))
50
105
  instance = new
51
106
  if accepts_turnkit_context?(instance)
52
107
  instance.call(**keyword_arguments, turnkit_context: context)
@@ -56,6 +111,64 @@ module TurnKit
56
111
  end
57
112
 
58
113
  private
114
+ def schema_for(param)
115
+ schema = {
116
+ "type" => schema_type(param.fetch(:type)),
117
+ "description" => param[:description].to_s
118
+ }.reject { |_key, value| value.nil? || value == "" }
119
+ schema["enum"] = Array(param[:enum]) if param[:enum]
120
+ schema["default"] = param[:default] if param.key?(:default)
121
+ schema["items"] = normalize_items(param[:items]) if param[:items]
122
+ schema["properties"] = normalize_properties(param[:properties]) if param[:properties]
123
+ schema
124
+ end
125
+
126
+ def schema_type(type)
127
+ type == :enum ? "string" : type.to_s
128
+ end
129
+
130
+ def normalize_items(value)
131
+ return { "type" => value.to_s } if value.is_a?(Symbol)
132
+
133
+ stringify_schema(value)
134
+ end
135
+
136
+ def normalize_properties(value)
137
+ value.to_h.transform_keys(&:to_s).transform_values { |schema| stringify_schema(schema) }
138
+ end
139
+
140
+ def stringify_schema(value)
141
+ case value
142
+ when Hash
143
+ value.transform_keys(&:to_s).transform_values { |nested| nested.is_a?(Hash) ? stringify_schema(nested) : nested }
144
+ else
145
+ { "type" => value.to_s }
146
+ end
147
+ end
148
+
149
+ def validate_value!(value, param)
150
+ return if value.nil? && !param.fetch(:required)
151
+
152
+ case param.fetch(:type)
153
+ when :string, :enum
154
+ raise ToolValidationError, "#{param.fetch(:name)} must be a string" unless value.is_a?(String)
155
+ when :integer
156
+ raise ToolValidationError, "#{param.fetch(:name)} must be an integer" unless value.is_a?(Integer)
157
+ when :number
158
+ raise ToolValidationError, "#{param.fetch(:name)} must be a number" unless value.is_a?(Numeric)
159
+ when :boolean
160
+ raise ToolValidationError, "#{param.fetch(:name)} must be a boolean" unless value == true || value == false
161
+ when :array
162
+ raise ToolValidationError, "#{param.fetch(:name)} must be an array" unless value.is_a?(Array)
163
+ when :object
164
+ raise ToolValidationError, "#{param.fetch(:name)} must be an object" unless value.is_a?(Hash)
165
+ end
166
+
167
+ if param[:enum] && !Array(param[:enum]).include?(value)
168
+ raise ToolValidationError, "#{param.fetch(:name)} must be one of: #{Array(param[:enum]).join(", ")}"
169
+ end
170
+ end
171
+
59
172
  def accepts_turnkit_context?(instance)
60
173
  instance.method(:call).parameters.any? { |kind, name| %i[key keyreq].include?(kind) && name == :turnkit_context }
61
174
  end
@@ -2,11 +2,12 @@
2
2
 
3
3
  module TurnKit
4
4
  class ToolCall
5
- attr_reader :id, :name, :arguments
5
+ attr_reader :id, :name, :arguments, :arguments_error
6
6
 
7
7
  def initialize(id:, name:, arguments: {})
8
8
  @id = id.to_s
9
9
  @name = name.to_s
10
+ @arguments_error = nil
10
11
  @arguments = normalize_arguments(arguments)
11
12
  end
12
13
 
@@ -22,6 +23,7 @@ module TurnKit
22
23
  {}
23
24
  end
24
25
  rescue JSON::ParserError
26
+ @arguments_error = "invalid JSON arguments"
25
27
  {}
26
28
  end
27
29
  end
@@ -31,6 +31,10 @@ module TurnKit
31
31
  return finish_error(execution, tool_call, "unknown tool: #{tool_call.name}")
32
32
  end
33
33
 
34
+ if tool_call.arguments_error
35
+ return finish_error(execution, tool_call, tool_call.arguments_error)
36
+ end
37
+
34
38
  context = ToolContext.new(turn: turn, execution: execution)
35
39
  payload = begin
36
40
  normalize_payload(tool.call(tool_call.arguments, context: context))
@@ -54,6 +58,7 @@ module TurnKit
54
58
  def finish_success(execution, tool_call, payload)
55
59
  attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
56
60
  append_result(execution, tool_call, payload)
61
+ turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name)
57
62
  ToolExecution.new(attrs)
58
63
  end
59
64
 
@@ -61,11 +66,12 @@ module TurnKit
61
66
  error = { "message" => message.to_s, "details" => details }.compact
62
67
  attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
63
68
  append_result(execution, tool_call, error)
69
+ turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error)
64
70
  ToolExecution.new(attrs)
65
71
  end
66
72
 
67
73
  def append_result(execution, tool_call, payload)
68
- turn.conversation.append_message(
74
+ message = turn.conversation.append_message(
69
75
  role: "tool",
70
76
  kind: "tool_result",
71
77
  text: payload.to_json,
@@ -73,6 +79,7 @@ module TurnKit
73
79
  tool_execution_id: execution.id,
74
80
  metadata: { "tool_call_id" => tool_call.id, "tool_name" => tool_call.name }
75
81
  )
82
+ turn.emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
76
83
  end
77
84
 
78
85
  def tool_class(name)