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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7aec304178bd79782515aaacd238e19de0556e64947406f14cdc8d6ccf05f71b
4
+ data.tar.gz: 92691d763c7cc007fb5cde9b9af2fbc28c69db9078b334f6b9a10892971a4644
5
+ SHA512:
6
+ metadata.gz: ab078a5e371c67774b9232ef90f3726fb630f1d1b4d13b71cdd09e51263074f9135ab87b9371e31e149b2d982e6ec4dea9950a2cf0fac7677fe334e950bc0071
7
+ data.tar.gz: c0637c433abbfc2e2c03bdc4632b3416c6596ffa20cc1855d537c68e4b54120033b78a435ffd8a311f9d29f8e0c0ae6f3ca06dd9a75d9a29f26e5a87f173bb78
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-06-04
4
+
5
+ - Initial release of TurnKit.
6
+ - Add durable conversations, turns, messages, tool calls, tool executions, and usage tracking.
7
+ - Add in-memory storage and optional Active Record-backed persistence.
8
+ - Add RubyLLM adapter support for model calls and provider API keys.
9
+ - Add tool, terminal-tool, skill, and sub-agent primitives.
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sam Couch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # TurnKit
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/turnkit.svg)](https://rubygems.org/gems/turnkit)
4
+ [![Build](https://github.com/samuelcouch/turnkit/actions/workflows/ci.yml/badge.svg)](https://github.com/samuelcouch/turnkit/actions)
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-red.svg)](https://www.ruby-lang.org)
6
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE.md)
7
+
8
+ Ruby AI agent runtime with durable turns, tools, skills, and Rails persistence.
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's **Gemfile**:
13
+
14
+ ```ruby
15
+ gem "turnkit"
16
+ ```
17
+
18
+ Run:
19
+
20
+ ```sh
21
+ bundle install
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ Set a provider key, then ask an agent:
27
+
28
+ ```sh
29
+ export ANTHROPIC_API_KEY=...
30
+ ```
31
+
32
+ ```ruby
33
+ require "turnkit"
34
+
35
+ agent = TurnKit::Agent.new(
36
+ name: "helper",
37
+ instructions: "Answer briefly."
38
+ )
39
+
40
+ turn = agent.conversation.ask("Explain Ruby blocks in one sentence.")
41
+ puts turn.output_text
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ Create a conversation:
47
+
48
+ ```ruby
49
+ agent = TurnKit::Agent.new(
50
+ name: "writer",
51
+ instructions: "Write clear release notes."
52
+ )
53
+
54
+ conversation = agent.conversation(subject: "v1 launch")
55
+ conversation.say("Mention faster tool execution.")
56
+
57
+ turn = conversation.run!
58
+ puts turn.output_text
59
+ ```
60
+
61
+ Create a tool:
62
+
63
+ ```ruby
64
+ class SaveReport < TurnKit::Tool
65
+ description "Save a report."
66
+ parameter :title, :string, required: true
67
+ parameter :body, :string, required: true
68
+
69
+ def self.ends_turn? = true
70
+ def self.completion_message(result) = "Saved #{result.fetch("report_id")}."
71
+
72
+ def call(title:, body:, context:)
73
+ { report_id: "rep_1", title: title, body: body }
74
+ end
75
+ end
76
+ ```
77
+
78
+ Use the tool:
79
+
80
+ ```ruby
81
+ agent = TurnKit::Agent.new(
82
+ name: "reporter",
83
+ instructions: "Save reports when asked.",
84
+ tools: [SaveReport]
85
+ )
86
+
87
+ turn = agent.conversation.ask("Save a short status report.")
88
+ puts turn.output_text
89
+ ```
90
+
91
+ Add skills:
92
+
93
+ ```ruby
94
+ skill = TurnKit::Skill.from_file("skills/research.md")
95
+
96
+ agent = TurnKit::Agent.new(
97
+ name: "researcher",
98
+ skills: [skill]
99
+ )
100
+ ```
101
+
102
+ Delegate to sub-agents:
103
+
104
+ ```ruby
105
+ writer = TurnKit::Agent.new(
106
+ name: "writer",
107
+ description: "Draft concise copy."
108
+ )
109
+
110
+ editor = TurnKit::Agent.new(
111
+ name: "editor",
112
+ sub_agents: [writer]
113
+ )
114
+
115
+ turn = editor.conversation.ask("Ask the writer for three headlines.")
116
+ puts turn.output_text
117
+ ```
118
+
119
+ Install Rails persistence:
120
+
121
+ ```sh
122
+ bin/rails generate turnkit:install
123
+ bin/rails db:migrate
124
+ ```
125
+
126
+ Configure Rails:
127
+
128
+ ```ruby
129
+ # config/initializers/turnkit.rb
130
+ TurnKit.store = TurnKit::ActiveRecordStore.new
131
+ TurnKit.default_model = "claude-sonnet-4-5"
132
+ TurnKit.timeout = 300
133
+ ```
134
+
135
+ Reconcile stale turns:
136
+
137
+ ```ruby
138
+ TurnKit.reconcile_stale!
139
+ ```
140
+
141
+ ## Options
142
+
143
+ Configure defaults globally:
144
+
145
+ ```ruby
146
+ TurnKit.default_model = "claude-sonnet-4-5"
147
+ TurnKit.max_iterations = 25
148
+ TurnKit.timeout = 300
149
+ TurnKit.max_depth = 3
150
+ TurnKit.max_tool_executions = 100
151
+ TurnKit.cost_limit = nil
152
+ ```
153
+
154
+ Override defaults per agent:
155
+
156
+ ```ruby
157
+ agent = TurnKit::Agent.new(
158
+ name: "analyst",
159
+ model: "gpt-4.1-mini",
160
+ max_iterations: 10,
161
+ timeout: 60,
162
+ cost_limit: 0.25
163
+ )
164
+ ```
165
+
166
+ | Option | Description |
167
+ | --- | --- |
168
+ | `default_model` | Default model for new turns. |
169
+ | `client` | Client adapter for model calls. |
170
+ | `store` | Store for conversations and turns. |
171
+ | `max_iterations` | Maximum model calls per turn. |
172
+ | `timeout` | Maximum seconds per root turn. |
173
+ | `max_depth` | Maximum sub-agent nesting depth. |
174
+ | `max_tool_executions` | Maximum tool calls per root turn. |
175
+ | `cost_limit` | Maximum cost per root turn. |
176
+
177
+ ## Contributing
178
+
179
+ Open bug reports and pull requests on GitHub:
180
+
181
+ ```text
182
+ https://github.com/samuelcouch/turnkit
183
+ ```
184
+
185
+ Run tests:
186
+
187
+ ```sh
188
+ bundle exec rake test
189
+ ```
190
+
191
+ ## License
192
+
193
+ See the MIT License.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ module Adapters
5
+ class RubyLLM < Client
6
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
7
+ require "ruby_llm"
8
+
9
+ configure_from_environment
10
+
11
+ chat = ::RubyLLM.chat(model: model)
12
+ chat.with_instructions(instructions) if instructions && !instructions.empty?
13
+ chat.with_temperature(temperature) if temperature
14
+ Array(tools).each { |tool| chat.with_tool(ruby_llm_tool(tool)) }
15
+ Array(messages).each { |message| add_message(chat, message) }
16
+
17
+ response = complete_without_tool_execution(chat)
18
+ normalize_response(response, model: model)
19
+ end
20
+
21
+ private
22
+ def configure_from_environment
23
+ config = ::RubyLLM.config
24
+ config.openai_api_key ||= ENV["OPENAI_API_KEY"]
25
+ config.gemini_api_key ||= ENV["GEMINI_API_KEY"]
26
+ config.anthropic_api_key ||= ENV["ANTHROPIC_API_KEY"]
27
+ config.openrouter_api_key ||= ENV["OPENROUTER_API_KEY"]
28
+ end
29
+
30
+ def complete_without_tool_execution(chat)
31
+ provider = chat.instance_variable_get(:@provider)
32
+ provider.complete(
33
+ chat.messages,
34
+ tools: chat.tools,
35
+ tool_prefs: chat.tool_prefs,
36
+ temperature: chat.instance_variable_get(:@temperature),
37
+ model: chat.model,
38
+ params: chat.params,
39
+ headers: chat.headers,
40
+ schema: chat.schema,
41
+ thinking: chat.instance_variable_get(:@thinking)
42
+ )
43
+ end
44
+
45
+ def add_message(chat, message)
46
+ role = (message[:role] || message["role"]).to_sym
47
+ content = message[:content] || message["content"] || ""
48
+ chat.add_message(
49
+ {
50
+ role: role,
51
+ content: content,
52
+ tool_calls: ruby_llm_tool_calls(message[:tool_calls] || message["tool_calls"]),
53
+ tool_call_id: message[:tool_call_id] || message["tool_call_id"]
54
+ }.compact
55
+ )
56
+ end
57
+
58
+ def ruby_llm_tool_calls(tool_calls)
59
+ return nil if tool_calls.nil? || tool_calls.empty?
60
+
61
+ calls = tool_calls.is_a?(Hash) ? tool_calls.values : Array(tool_calls)
62
+ calls.to_h do |tool_call|
63
+ attrs = tool_call.respond_to?(:to_h) ? tool_call.to_h : tool_call
64
+ attrs = attrs.transform_keys(&:to_s)
65
+ id = attrs.fetch("id")
66
+ [ id, ::RubyLLM::ToolCall.new(id: id, name: attrs.fetch("name"), arguments: attrs["arguments"] || {}) ]
67
+ end
68
+ end
69
+
70
+ def ruby_llm_tool(tool)
71
+ require "ruby_llm"
72
+
73
+ Class.new(::RubyLLM::Tool) do
74
+ define_singleton_method(:name) { tool.tool_name }
75
+ description tool.description
76
+ tool.parameters.each do |param|
77
+ param(param.fetch(:name).to_sym, type: param.fetch(:type), required: param.fetch(:required), desc: param.fetch(:description))
78
+ end
79
+
80
+ define_method(:execute) do |**arguments|
81
+ raise ToolError, "tools must be executed by TurnKit turns, not the RubyLLM adapter"
82
+ end
83
+ end
84
+ end
85
+
86
+ def normalize_response(response, model:)
87
+ tool_calls = Array(response.respond_to?(:tool_calls) ? response.tool_calls&.values : []).map do |call|
88
+ ToolCall.new(id: call.id, name: call.name, arguments: call.arguments)
89
+ end
90
+ usage = Usage.new(
91
+ input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : 0,
92
+ output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : 0,
93
+ cached_tokens: response.respond_to?(:cached_tokens) ? response.cached_tokens : 0
94
+ )
95
+ Result.new(
96
+ text: response.respond_to?(:content) ? response.content.to_s : response.to_s,
97
+ tool_calls: tool_calls,
98
+ usage: usage,
99
+ model: response.respond_to?(:model_id) ? response.model_id : model
100
+ )
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Agent
5
+ attr_reader :name, :description, :model, :instructions, :tools, :skills, :sub_agents
6
+ attr_reader :client, :store, :max_iterations, :timeout, :cost_limit, :max_depth, :max_tool_executions
7
+
8
+ def initialize(name:, description: "", model: nil, instructions: "", tools: [], skills: [], sub_agents: [], client: nil, store: nil,
9
+ max_iterations: nil, timeout: nil, cost_limit: nil, max_depth: nil, max_tool_executions: nil)
10
+ @name = name.to_s
11
+ @description = description.to_s
12
+ @model = model
13
+ @instructions = instructions.to_s
14
+ @tools = Array(tools)
15
+ @skills = Array(skills)
16
+ @sub_agents = Array(sub_agents)
17
+ @client = client
18
+ @store = store
19
+ @max_iterations = max_iterations
20
+ @timeout = timeout
21
+ @cost_limit = cost_limit
22
+ @max_depth = max_depth
23
+ @max_tool_executions = max_tool_executions
24
+ raise ArgumentError, "name is required" if @name.empty?
25
+ end
26
+
27
+ def conversation(model: nil, subject: nil, metadata: {})
28
+ store = effective_store
29
+ record = store.create_conversation(
30
+ "agent_name" => name,
31
+ "model" => model || effective_model,
32
+ "subject" => subject,
33
+ "metadata" => metadata
34
+ )
35
+ Conversation.new(agent: self, record: record, store: store, model: model || effective_model, subject: subject, metadata: metadata)
36
+ end
37
+
38
+ def effective_model
39
+ model || TurnKit.default_model
40
+ end
41
+
42
+ def effective_client
43
+ client || TurnKit.client
44
+ end
45
+
46
+ def effective_store
47
+ store || TurnKit.store
48
+ end
49
+
50
+ def effective_tools
51
+ tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
52
+ end
53
+
54
+ def build_budget(root_started_at: Clock.now)
55
+ Budget.new(
56
+ max_iterations: max_iterations || TurnKit.max_iterations,
57
+ timeout: timeout || TurnKit.timeout,
58
+ max_depth: max_depth || TurnKit.max_depth,
59
+ max_tool_executions: max_tool_executions || TurnKit.max_tool_executions,
60
+ cost_limit: cost_limit || TurnKit.cost_limit,
61
+ root_started_at: root_started_at
62
+ )
63
+ end
64
+
65
+ def instructions_with_skills
66
+ parts = [ instructions ]
67
+ skills.each do |skill|
68
+ parts << "## Skill: #{skill.name}\n\n#{skill.content}"
69
+ end
70
+ parts.reject(&:empty?).join("\n\n")
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Budget
5
+ attr_reader :root_started_at, :max_iterations, :timeout, :max_depth, :max_tool_executions, :cost_limit
6
+
7
+ def initialize(max_iterations:, timeout:, max_depth:, max_tool_executions:, cost_limit: nil, root_started_at: Clock.now)
8
+ @root_started_at = root_started_at
9
+ @max_iterations = max_iterations
10
+ @timeout = timeout
11
+ @max_depth = max_depth
12
+ @max_tool_executions = max_tool_executions
13
+ @cost_limit = cost_limit
14
+ @iterations = 0
15
+ @tool_executions = 0
16
+ @cost = 0
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ def count_iteration!
21
+ @mutex.synchronize do
22
+ @iterations += 1
23
+ raise Error, "maximum iterations reached" if max_iterations && @iterations > max_iterations
24
+ end
25
+ end
26
+
27
+ def count_tool_execution!
28
+ @mutex.synchronize do
29
+ @tool_executions += 1
30
+ raise Error, "maximum tool executions reached" if max_tool_executions && @tool_executions > max_tool_executions
31
+ end
32
+ end
33
+
34
+ def add_usage!(usage)
35
+ return unless usage&.cost && cost_limit
36
+
37
+ @mutex.synchronize do
38
+ @cost += usage.cost.to_f
39
+ raise Error, "cost limit reached" if @cost > cost_limit
40
+ end
41
+ end
42
+
43
+ def check!(depth:)
44
+ raise Error, "maximum sub-agent depth reached" if max_depth && depth > max_depth
45
+ raise Error, "turn timed out" if timeout && Clock.now >= root_started_at + timeout
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Client
5
+ def chat(model:, messages:, tools:, instructions:, temperature: nil, metadata: nil)
6
+ raise NotImplementedError
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ module Clock
5
+ module_function
6
+
7
+ def now
8
+ Time.now.utc
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Conversation
5
+ attr_reader :agent, :id, :store, :model, :subject, :metadata
6
+
7
+ def initialize(agent:, record:, store:, model:, subject: nil, metadata: {})
8
+ @agent = agent
9
+ @record = record.transform_keys(&:to_s)
10
+ @id = @record.fetch("id")
11
+ @store = store
12
+ @model = model
13
+ @subject = subject
14
+ @metadata = metadata || {}
15
+ end
16
+
17
+ def say(text, metadata: {})
18
+ append_message(role: "user", kind: "text", text: text, metadata: metadata)
19
+ end
20
+
21
+ def ask(text, async: false, **options)
22
+ trigger = say(text)
23
+ turn = build_turn(trigger_message_id: trigger.id, **options)
24
+ async ? turn : turn.run!
25
+ end
26
+
27
+ def run!(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent)
28
+ 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).run!
29
+ end
30
+
31
+ def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, depth: 0, agent: self.agent)
32
+ snapshot = latest_message_sequence
33
+ record = store.create_turn(
34
+ "conversation_id" => id,
35
+ "agent_name" => agent.name,
36
+ "parent_turn_id" => parent_turn&.id,
37
+ "parent_tool_execution_id" => parent_tool_execution&.id,
38
+ "root_turn_id" => parent_turn&.root_turn_id,
39
+ "context_message_sequence" => snapshot,
40
+ "status" => "pending",
41
+ "model" => model || self.model || agent.effective_model,
42
+ "options" => { "trigger_message_id" => trigger_message_id }.compact
43
+ )
44
+ Turn.new(agent: agent, conversation: self, record: record, store: store, budget: budget, depth: depth)
45
+ end
46
+
47
+ def messages
48
+ store.list_messages(id).map { |attrs| Message.new(attrs) }
49
+ end
50
+
51
+ def messages_for_turn(turn)
52
+ store.list_messages(id, through_sequence: turn.context_message_sequence, turn_id: turn.id).map { |attrs| Message.new(attrs) }
53
+ end
54
+
55
+ def append_message(role:, kind:, text: nil, content: nil, turn_id: nil, tool_execution_id: nil, metadata: {})
56
+ attrs = store.append_message(
57
+ "conversation_id" => id,
58
+ "turn_id" => turn_id,
59
+ "role" => role,
60
+ "kind" => kind,
61
+ "text" => text,
62
+ "content" => content,
63
+ "tool_execution_id" => tool_execution_id,
64
+ "metadata" => metadata
65
+ )
66
+ Message.new(attrs)
67
+ end
68
+
69
+ def latest_message_sequence
70
+ if store.respond_to?(:latest_message_sequence)
71
+ store.latest_message_sequence(id)
72
+ else
73
+ messages.map(&:sequence).max.to_i
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Error < StandardError; end
5
+ class ConfigError < Error; end
6
+ class StoreError < Error; end
7
+ class ToolError < Error; end
8
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turnkit
4
+ class Conversation < ApplicationRecord
5
+ self.table_name = "<%= table_prefix %>_conversations"
6
+
7
+ has_many :turns, class_name: "Turnkit::Turn", foreign_key: :conversation_uid, primary_key: :uid, dependent: :destroy, inverse_of: :conversation
8
+ has_many :messages, class_name: "Turnkit::Message", foreign_key: :conversation_uid, primary_key: :uid, dependent: :destroy, inverse_of: :conversation
9
+ end
10
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTurnkitTables < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :<%= table_prefix %>_conversations do |t|
6
+ t.string :uid, null: false
7
+ t.string :agent_name, null: false
8
+ t.string :model
9
+ t.string :subject_type
10
+ t.string :subject_id
11
+ t.json :metadata, null: false, default: {}
12
+ t.timestamps
13
+
14
+ t.index :uid, unique: true
15
+ t.index [ :subject_type, :subject_id ]
16
+ end
17
+
18
+ create_table :<%= table_prefix %>_turns do |t|
19
+ t.string :uid, null: false
20
+ t.string :conversation_uid, null: false
21
+ t.string :agent_name, null: false
22
+ t.string :parent_turn_uid
23
+ t.string :parent_tool_execution_uid
24
+ t.string :root_turn_uid, null: false
25
+ t.integer :context_message_sequence, null: false, default: 0
26
+ t.string :status, null: false, default: "pending"
27
+ t.string :model
28
+ t.json :options, null: false, default: {}
29
+ t.json :usage, null: false, default: {}
30
+ t.decimal :cost, precision: 14, scale: 6
31
+ t.json :error
32
+ t.text :output_text
33
+ t.datetime :started_at
34
+ t.datetime :heartbeat_at
35
+ t.datetime :completed_at
36
+ t.timestamps
37
+
38
+ t.index :uid, unique: true
39
+ t.index :conversation_uid
40
+ t.index :root_turn_uid
41
+ t.index [ :status, :heartbeat_at ]
42
+ end
43
+
44
+ create_table :<%= table_prefix %>_messages do |t|
45
+ t.string :uid, null: false
46
+ t.string :conversation_uid, null: false
47
+ t.string :turn_uid
48
+ t.string :role, null: false
49
+ t.string :kind, null: false
50
+ t.integer :sequence, null: false
51
+ t.json :content, null: false, default: []
52
+ t.text :text
53
+ t.string :tool_execution_uid
54
+ t.string :provider_message_id
55
+ t.json :metadata, null: false, default: {}
56
+ t.timestamps
57
+
58
+ t.index :uid, unique: true
59
+ t.index [ :conversation_uid, :sequence ], unique: true
60
+ t.index [ :conversation_uid, :turn_uid ]
61
+ t.index :turn_uid
62
+ end
63
+
64
+ create_table :<%= table_prefix %>_tool_executions do |t|
65
+ t.string :uid, null: false
66
+ t.string :turn_uid, null: false
67
+ t.string :tool_call_id, null: false
68
+ t.string :tool_name, null: false
69
+ t.string :status, null: false, default: "pending"
70
+ t.json :arguments, null: false, default: {}
71
+ t.json :result
72
+ t.json :error
73
+ t.datetime :started_at
74
+ t.datetime :completed_at
75
+ t.timestamps
76
+
77
+ t.index :uid, unique: true
78
+ t.index [ :turn_uid, :tool_call_id ], unique: true
79
+ t.index [ :turn_uid, :status ]
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ TurnKit.store = TurnKit::ActiveRecordStore.new
4
+
5
+ TurnKit.conversation_record_class = "Turnkit::Conversation"
6
+ TurnKit.turn_record_class = "Turnkit::Turn"
7
+ TurnKit.message_record_class = "Turnkit::Message"
8
+ TurnKit.tool_execution_record_class = "Turnkit::ToolExecution"
9
+
10
+ # TurnKit.default_model = "claude-sonnet-4-5"
11
+ # TurnKit.max_iterations = 25
12
+ # TurnKit.timeout = 300
13
+ # TurnKit.max_depth = 3
14
+ # TurnKit.max_tool_executions = 100
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turnkit
4
+ class Message < ApplicationRecord
5
+ self.table_name = "<%= table_prefix %>_messages"
6
+
7
+ belongs_to :conversation, class_name: "Turnkit::Conversation", foreign_key: :conversation_uid, primary_key: :uid, inverse_of: :messages
8
+ belongs_to :turn, class_name: "Turnkit::Turn", foreign_key: :turn_uid, primary_key: :uid, optional: true, inverse_of: :messages
9
+ belongs_to :tool_execution, class_name: "Turnkit::ToolExecution", foreign_key: :tool_execution_uid, primary_key: :uid, optional: true
10
+ end
11
+ end