turnkit 0.1.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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +193 -0
  5. data/lib/turnkit/adapters/ruby_llm.rb +104 -0
  6. data/lib/turnkit/agent.rb +73 -0
  7. data/lib/turnkit/budget.rb +48 -0
  8. data/lib/turnkit/client.rb +9 -0
  9. data/lib/turnkit/clock.rb +11 -0
  10. data/lib/turnkit/conversation.rb +77 -0
  11. data/lib/turnkit/error.rb +8 -0
  12. data/lib/turnkit/generators/turnkit/install/templates/conversation.rb +10 -0
  13. data/lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb +82 -0
  14. data/lib/turnkit/generators/turnkit/install/templates/initializer.rb +14 -0
  15. data/lib/turnkit/generators/turnkit/install/templates/message.rb +11 -0
  16. data/lib/turnkit/generators/turnkit/install/templates/tool_execution.rb +10 -0
  17. data/lib/turnkit/generators/turnkit/install/templates/turn.rb +14 -0
  18. data/lib/turnkit/generators/turnkit/install_generator.rb +44 -0
  19. data/lib/turnkit/id.rb +19 -0
  20. data/lib/turnkit/memory_store.rb +130 -0
  21. data/lib/turnkit/message.rb +67 -0
  22. data/lib/turnkit/message_projection.rb +27 -0
  23. data/lib/turnkit/rails/railtie.rb +9 -0
  24. data/lib/turnkit/record.rb +116 -0
  25. data/lib/turnkit/result.rb +19 -0
  26. data/lib/turnkit/skill.rb +23 -0
  27. data/lib/turnkit/store.rb +24 -0
  28. data/lib/turnkit/stores/active_record_store.rb +190 -0
  29. data/lib/turnkit/sub_agent_tool.rb +38 -0
  30. data/lib/turnkit/tool.rb +63 -0
  31. data/lib/turnkit/tool_call.rb +28 -0
  32. data/lib/turnkit/tool_execution.rb +28 -0
  33. data/lib/turnkit/tool_runner.rb +90 -0
  34. data/lib/turnkit/turn.rb +142 -0
  35. data/lib/turnkit/usage.rb +28 -0
  36. data/lib/turnkit/version.rb +5 -0
  37. data/lib/turnkit.rb +56 -0
  38. metadata +100 -0
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turnkit
4
+ class ToolExecution < ApplicationRecord
5
+ self.table_name = "<%= table_prefix %>_tool_executions"
6
+
7
+ belongs_to :turn, class_name: "Turnkit::Turn", foreign_key: :turn_uid, primary_key: :uid, inverse_of: :tool_executions
8
+ has_many :messages, class_name: "Turnkit::Message", foreign_key: :tool_execution_uid, primary_key: :uid, dependent: :nullify
9
+ end
10
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turnkit
4
+ class Turn < ApplicationRecord
5
+ self.table_name = "<%= table_prefix %>_turns"
6
+
7
+ belongs_to :conversation, class_name: "Turnkit::Conversation", foreign_key: :conversation_uid, primary_key: :uid, inverse_of: :turns
8
+ belongs_to :parent_turn, class_name: "Turnkit::Turn", foreign_key: :parent_turn_uid, primary_key: :uid, optional: true
9
+ belongs_to :parent_tool_execution, class_name: "Turnkit::ToolExecution", foreign_key: :parent_tool_execution_uid, primary_key: :uid, optional: true
10
+
11
+ has_many :messages, class_name: "Turnkit::Message", foreign_key: :turn_uid, primary_key: :uid, dependent: :nullify, inverse_of: :turn
12
+ has_many :tool_executions, class_name: "Turnkit::ToolExecution", foreign_key: :turn_uid, primary_key: :uid, dependent: :destroy, inverse_of: :turn
13
+ end
14
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module TurnKit
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("install/templates", __dir__)
12
+
13
+ class_option :table_prefix, type: :string, default: "turnkit", desc: "Database table prefix."
14
+
15
+ def copy_initializer
16
+ template "initializer.rb", "config/initializers/turnkit.rb"
17
+ end
18
+
19
+ def copy_models
20
+ template "conversation.rb", "app/models/turnkit/conversation.rb"
21
+ template "turn.rb", "app/models/turnkit/turn.rb"
22
+ template "message.rb", "app/models/turnkit/message.rb"
23
+ template "tool_execution.rb", "app/models/turnkit/tool_execution.rb"
24
+ end
25
+
26
+ def copy_migration
27
+ migration_template "create_turnkit_tables.rb", "db/migrate/create_turnkit_tables.rb"
28
+ end
29
+
30
+ def self.next_migration_number(dirname)
31
+ if ActiveRecord::Base.timestamped_migrations
32
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
33
+ else
34
+ "%.3d" % (current_migration_number(dirname) + 1)
35
+ end
36
+ end
37
+
38
+ private
39
+ def table_prefix
40
+ options[:table_prefix]
41
+ end
42
+ end
43
+ end
44
+ end
data/lib/turnkit/id.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ module Id
5
+ PREFIXES = {
6
+ conversation: "conv",
7
+ message: "msg",
8
+ turn: "turn",
9
+ tool_execution: "tool"
10
+ }.freeze
11
+
12
+ module_function
13
+
14
+ def generate(type)
15
+ prefix = PREFIXES.fetch(type)
16
+ "#{prefix}_#{SecureRandom.hex(12)}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class MemoryStore < Store
5
+ def initialize
6
+ @mutex = Mutex.new
7
+ @conversations = {}
8
+ @turns = {}
9
+ @messages = {}
10
+ @tool_executions = {}
11
+ @message_sequences = Hash.new(0)
12
+ end
13
+
14
+ def create_conversation(attributes)
15
+ record = Record.conversation(attributes)
16
+
17
+ @mutex.synchronize { @conversations[record.fetch("id")] = record }
18
+ record.dup
19
+ end
20
+
21
+ def load_conversation(id)
22
+ @mutex.synchronize { duplicate(@conversations.fetch(id)) }
23
+ end
24
+
25
+ def next_message_sequence(conversation_id)
26
+ @mutex.synchronize do
27
+ @message_sequences[conversation_id] += 1
28
+ end
29
+ end
30
+
31
+ def latest_message_sequence(conversation_id)
32
+ @mutex.synchronize { @message_sequences[conversation_id].to_i }
33
+ end
34
+
35
+ def append_message(attributes)
36
+ attrs = stringify(attributes)
37
+ attrs["sequence"] ||= next_message_sequence(attrs.fetch("conversation_id"))
38
+ message = Record.message(attrs)
39
+ @mutex.synchronize { @messages[message.fetch("id")] = message }
40
+ duplicate(message)
41
+ end
42
+
43
+ def list_messages(conversation_id, through_sequence: nil, turn_id: nil)
44
+ @mutex.synchronize do
45
+ rows = @messages.values.select { |message| message["conversation_id"] == conversation_id }
46
+ rows = rows.select { |message| message["sequence"].to_i <= through_sequence.to_i || message["turn_id"] == turn_id } if through_sequence
47
+ rows.sort_by { |message| [ message["sequence"].to_i, message["created_at"].to_f, message["id"] ] }.map { |message| duplicate(message) }
48
+ end
49
+ end
50
+
51
+ def create_turn(attributes)
52
+ record = Record.turn(attributes)
53
+
54
+ @mutex.synchronize { @turns[record.fetch("id")] = record }
55
+ duplicate(record)
56
+ end
57
+
58
+ def load_turn(id)
59
+ @mutex.synchronize { duplicate(@turns.fetch(id)) }
60
+ end
61
+
62
+ def update_turn(id, attributes)
63
+ attrs = Record.turn_update(attributes)
64
+ @mutex.synchronize do
65
+ record = @turns.fetch(id)
66
+ record.merge!(attrs.merge("updated_at" => Clock.now))
67
+ duplicate(record)
68
+ end
69
+ end
70
+
71
+ def list_turns(root_turn_id: nil, conversation_id: nil)
72
+ @mutex.synchronize do
73
+ rows = @turns.values
74
+ rows = rows.select { |turn| turn["root_turn_id"] == root_turn_id } if root_turn_id
75
+ rows = rows.select { |turn| turn["conversation_id"] == conversation_id } if conversation_id
76
+ rows.sort_by { |turn| [ turn["created_at"].to_f, turn["id"] ] }.map { |turn| duplicate(turn) }
77
+ end
78
+ end
79
+
80
+ def create_tool_execution(attributes)
81
+ record = Record.tool_execution(attributes)
82
+
83
+ @mutex.synchronize { @tool_executions[record.fetch("id")] = record }
84
+ duplicate(record)
85
+ end
86
+
87
+ def load_tool_execution(id)
88
+ @mutex.synchronize { duplicate(@tool_executions.fetch(id)) }
89
+ end
90
+
91
+ def update_tool_execution(id, attributes)
92
+ attrs = Record.tool_execution_update(attributes)
93
+ @mutex.synchronize do
94
+ record = @tool_executions.fetch(id)
95
+ record.merge!(attrs.merge("updated_at" => Clock.now))
96
+ duplicate(record)
97
+ end
98
+ end
99
+
100
+ def list_tool_executions(turn_id:)
101
+ @mutex.synchronize do
102
+ @tool_executions.values
103
+ .select { |execution| execution["turn_id"] == turn_id }
104
+ .sort_by { |execution| [ execution["created_at"].to_f, execution["id"] ] }
105
+ .map { |execution| duplicate(execution) }
106
+ end
107
+ end
108
+
109
+ def find_stale_turns(before:)
110
+ @mutex.synchronize do
111
+ @turns.values.select do |turn|
112
+ %w[pending running].include?(turn["status"]) && stale_anchor(turn) && stale_anchor(turn) < before
113
+ end.map { |turn| duplicate(turn) }
114
+ end
115
+ end
116
+
117
+ private
118
+ def stringify(hash)
119
+ hash.transform_keys(&:to_s)
120
+ end
121
+
122
+ def duplicate(value)
123
+ Marshal.load(Marshal.dump(value))
124
+ end
125
+
126
+ def stale_anchor(turn)
127
+ turn["heartbeat_at"] || turn["started_at"] || turn["created_at"]
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Message
5
+ ROLES = %w[user assistant tool].freeze
6
+ KINDS = %w[text tool_call tool_result].freeze
7
+
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
10
+
11
+ def initialize(attributes = {})
12
+ attrs = stringify(attributes)
13
+ @id = attrs["id"] || Id.generate(:message)
14
+ @conversation_id = attrs.fetch("conversation_id")
15
+ @turn_id = attrs["turn_id"]
16
+ @role = attrs.fetch("role").to_s
17
+ @kind = attrs.fetch("kind", "text").to_s
18
+ @sequence = attrs.fetch("sequence").to_i
19
+ @content = normalize_content(attrs["content"] || attrs["text"])
20
+ @text = attrs["text"] || extract_text(@content)
21
+ @tool_execution_id = attrs["tool_execution_id"]
22
+ @provider_message_id = attrs["provider_message_id"]
23
+ @metadata = attrs["metadata"] || {}
24
+ @created_at = attrs["created_at"] || Clock.now
25
+
26
+ validate!
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ "id" => id,
32
+ "conversation_id" => conversation_id,
33
+ "turn_id" => turn_id,
34
+ "role" => role,
35
+ "kind" => kind,
36
+ "sequence" => sequence,
37
+ "content" => content,
38
+ "text" => text,
39
+ "tool_execution_id" => tool_execution_id,
40
+ "provider_message_id" => provider_message_id,
41
+ "metadata" => metadata,
42
+ "created_at" => created_at
43
+ }
44
+ end
45
+
46
+ private
47
+ def stringify(hash)
48
+ hash.transform_keys(&:to_s)
49
+ end
50
+
51
+ def normalize_content(value)
52
+ return value if value.is_a?(Array)
53
+
54
+ [ { "type" => "text", "text" => value.to_s } ]
55
+ end
56
+
57
+ def extract_text(blocks)
58
+ Array(blocks).filter_map { |block| block.is_a?(Hash) ? block["text"] || block[:text] : nil }.join("\n")
59
+ end
60
+
61
+ def validate!
62
+ raise ArgumentError, "unknown role: #{role}" unless ROLES.include?(role)
63
+ raise ArgumentError, "unknown kind: #{kind}" unless KINDS.include?(kind)
64
+ raise ArgumentError, "sequence must be positive" unless sequence.positive?
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class MessageProjection
5
+ def self.for(messages)
6
+ messages.map { |message| new(message).to_h }
7
+ end
8
+
9
+ def initialize(message)
10
+ @message = message
11
+ end
12
+
13
+ def to_h
14
+ case message.kind
15
+ when "tool_call"
16
+ { role: :assistant, content: message.text, tool_calls: message.metadata.fetch("tool_calls", []) }
17
+ when "tool_result"
18
+ { role: :tool, content: message.text, tool_call_id: message.metadata["tool_call_id"] }
19
+ else
20
+ { role: message.role.to_sym, content: message.text }
21
+ end
22
+ end
23
+
24
+ private
25
+ attr_reader :message
26
+ end
27
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Railtie < Rails::Railtie
5
+ generators do
6
+ require_relative "../generators/turnkit/install_generator"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ module Record
5
+ TURN_STATUSES = %w[pending running completed failed cancelled stale].freeze
6
+ TOOL_EXECUTION_STATUSES = %w[pending running completed failed cancelled].freeze
7
+
8
+ TURN_UPDATE_KEYS = %w[status options usage cost error output_text started_at heartbeat_at completed_at].freeze
9
+ TOOL_EXECUTION_UPDATE_KEYS = %w[status result error started_at completed_at].freeze
10
+
11
+ module_function
12
+
13
+ def conversation(attributes)
14
+ attrs = stringify(attributes)
15
+ now = Clock.now
16
+ {
17
+ "id" => attrs["id"] || Id.generate(:conversation),
18
+ "agent_name" => attrs["agent_name"],
19
+ "model" => attrs["model"],
20
+ "subject" => attrs["subject"],
21
+ "metadata" => attrs["metadata"] || {},
22
+ "created_at" => attrs["created_at"] || now,
23
+ "updated_at" => attrs["updated_at"] || now
24
+ }
25
+ end
26
+
27
+ def message(attributes)
28
+ Message.new(attributes).to_h
29
+ end
30
+
31
+ def turn(attributes)
32
+ attrs = stringify(attributes)
33
+ id = attrs["id"] || Id.generate(:turn)
34
+ status = attrs["status"] || "pending"
35
+ assert_status!(status, TURN_STATUSES, "turn")
36
+ now = Clock.now
37
+ {
38
+ "id" => id,
39
+ "conversation_id" => attrs.fetch("conversation_id"),
40
+ "agent_name" => attrs["agent_name"],
41
+ "parent_turn_id" => attrs["parent_turn_id"],
42
+ "parent_tool_execution_id" => attrs["parent_tool_execution_id"],
43
+ "root_turn_id" => attrs["root_turn_id"] || id,
44
+ "context_message_sequence" => attrs["context_message_sequence"].to_i,
45
+ "status" => status,
46
+ "model" => attrs["model"],
47
+ "options" => attrs["options"] || {},
48
+ "usage" => attrs["usage"] || {},
49
+ "cost" => attrs["cost"],
50
+ "error" => attrs["error"],
51
+ "output_text" => attrs["output_text"],
52
+ "started_at" => attrs["started_at"],
53
+ "heartbeat_at" => attrs["heartbeat_at"],
54
+ "completed_at" => attrs["completed_at"],
55
+ "created_at" => attrs["created_at"] || now,
56
+ "updated_at" => attrs["updated_at"] || now
57
+ }
58
+ end
59
+
60
+ def tool_execution(attributes)
61
+ attrs = stringify(attributes)
62
+ status = attrs["status"] || "pending"
63
+ assert_status!(status, TOOL_EXECUTION_STATUSES, "tool execution")
64
+ now = Clock.now
65
+ {
66
+ "id" => attrs["id"] || Id.generate(:tool_execution),
67
+ "turn_id" => attrs.fetch("turn_id"),
68
+ "tool_call_id" => attrs.fetch("tool_call_id"),
69
+ "tool_name" => attrs.fetch("tool_name"),
70
+ "status" => status,
71
+ "arguments" => attrs["arguments"] || {},
72
+ "result" => attrs["result"],
73
+ "error" => attrs["error"],
74
+ "started_at" => attrs["started_at"],
75
+ "completed_at" => attrs["completed_at"],
76
+ "created_at" => attrs["created_at"] || now,
77
+ "updated_at" => attrs["updated_at"] || now
78
+ }
79
+ end
80
+
81
+ def turn_update(attributes)
82
+ update(attributes, TURN_UPDATE_KEYS, TURN_STATUSES, "turn")
83
+ end
84
+
85
+ def tool_execution_update(attributes)
86
+ update(attributes, TOOL_EXECUTION_UPDATE_KEYS, TOOL_EXECUTION_STATUSES, "tool execution")
87
+ end
88
+
89
+ def stringify(hash)
90
+ hash.transform_keys(&:to_s)
91
+ end
92
+
93
+ def subject_pair(subject)
94
+ case subject
95
+ when Hash
96
+ attrs = stringify(subject)
97
+ [ attrs["type"] || attrs["class"], attrs["id"] ]
98
+ else
99
+ [ subject&.class&.name, subject&.respond_to?(:id) ? subject.id : nil ]
100
+ end
101
+ end
102
+
103
+ def assert_status!(status, allowed, name)
104
+ raise ArgumentError, "unknown #{name} status: #{status}" unless allowed.include?(status.to_s)
105
+ end
106
+
107
+ def update(attributes, allowed_keys, allowed_statuses, name)
108
+ attrs = stringify(attributes)
109
+ unknown = attrs.keys - allowed_keys
110
+ raise ArgumentError, "unknown #{name} update attributes: #{unknown.join(", ")}" if unknown.any?
111
+
112
+ assert_status!(attrs["status"], allowed_statuses, name) if attrs.key?("status")
113
+ attrs
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Result
5
+ attr_reader :text, :tool_calls, :usage, :model, :finish_reason
6
+
7
+ def initialize(text: "", tool_calls: [], usage: Usage.new, model: nil, finish_reason: nil)
8
+ @text = text.to_s
9
+ @tool_calls = Array(tool_calls)
10
+ @usage = usage || Usage.new
11
+ @model = model
12
+ @finish_reason = finish_reason
13
+ end
14
+
15
+ def tool_calls?
16
+ tool_calls.any?
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Skill
5
+ attr_reader :key, :name, :description, :content
6
+
7
+ def self.from_file(path, key: nil, name: nil, description: "")
8
+ content = File.read(path)
9
+ base = File.basename(path, File.extname(path))
10
+ new(key: key || base, name: name || base.tr("_-", " ").split.map(&:capitalize).join(" "), description: description, content: content)
11
+ end
12
+
13
+ def initialize(key:, name:, content:, description: "")
14
+ @key = key.to_s
15
+ @name = name.to_s
16
+ @description = description.to_s
17
+ @content = content.to_s
18
+ raise ArgumentError, "key is required" if @key.empty?
19
+ raise ArgumentError, "name is required" if @name.empty?
20
+ raise ArgumentError, "content is required" if @content.empty?
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Store
5
+ def create_conversation(_attributes) = raise(NotImplementedError)
6
+ def load_conversation(_id) = raise(NotImplementedError)
7
+
8
+ def next_message_sequence(_conversation_id) = raise(NotImplementedError)
9
+ def append_message(_attributes) = raise(NotImplementedError)
10
+ def list_messages(_conversation_id, through_sequence: nil, turn_id: nil) = raise(NotImplementedError)
11
+
12
+ def create_turn(_attributes) = raise(NotImplementedError)
13
+ def load_turn(_id) = raise(NotImplementedError)
14
+ def update_turn(_id, _attributes) = raise(NotImplementedError)
15
+ def list_turns(root_turn_id: nil, conversation_id: nil) = raise(NotImplementedError)
16
+
17
+ def create_tool_execution(_attributes) = raise(NotImplementedError)
18
+ def load_tool_execution(_id) = raise(NotImplementedError)
19
+ def update_tool_execution(_id, _attributes) = raise(NotImplementedError)
20
+ def list_tool_executions(turn_id:) = raise(NotImplementedError)
21
+
22
+ def find_stale_turns(before:) = []
23
+ end
24
+ end