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.
- checksums.yaml +4 -4
- data/README.md +146 -401
- data/lib/turnkit/adapters/ruby_llm.rb +69 -5
- data/lib/turnkit/agent.rb +19 -2
- data/lib/turnkit/client.rb +5 -1
- data/lib/turnkit/conversation.rb +5 -4
- data/lib/turnkit/error.rb +2 -0
- data/lib/turnkit/event.rb +25 -0
- data/lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb +1 -0
- data/lib/turnkit/generators/turnkit/install/templates/initializer.rb +6 -0
- data/lib/turnkit/generators/turnkit/install_generator.rb +6 -0
- data/lib/turnkit/model_request.rb +35 -0
- data/lib/turnkit/record.rb +2 -1
- data/lib/turnkit/result.rb +3 -2
- data/lib/turnkit/stores/active_record_store.rb +14 -4
- data/lib/turnkit/sub_agent_tool.rb +13 -4
- data/lib/turnkit/tool.rb +117 -4
- data/lib/turnkit/tool_call.rb +3 -1
- data/lib/turnkit/tool_runner.rb +8 -1
- data/lib/turnkit/turn.rb +92 -15
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +4 -0
- metadata +6 -5
|
@@ -3,7 +3,28 @@
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
module Adapters
|
|
5
5
|
class RubyLLM < Client
|
|
6
|
-
|
|
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.
|
|
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
|
|
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
|
data/lib/turnkit/client.rb
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Client
|
|
5
|
-
def
|
|
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
|
data/lib/turnkit/conversation.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
data/lib/turnkit/record.rb
CHANGED
|
@@ -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"],
|
data/lib/turnkit/result.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/turnkit/tool_call.rb
CHANGED
|
@@ -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
|
data/lib/turnkit/tool_runner.rb
CHANGED
|
@@ -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)
|