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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.md +21 -0
- data/README.md +193 -0
- data/lib/turnkit/adapters/ruby_llm.rb +104 -0
- data/lib/turnkit/agent.rb +73 -0
- data/lib/turnkit/budget.rb +48 -0
- data/lib/turnkit/client.rb +9 -0
- data/lib/turnkit/clock.rb +11 -0
- data/lib/turnkit/conversation.rb +77 -0
- data/lib/turnkit/error.rb +8 -0
- data/lib/turnkit/generators/turnkit/install/templates/conversation.rb +10 -0
- data/lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb +82 -0
- data/lib/turnkit/generators/turnkit/install/templates/initializer.rb +14 -0
- data/lib/turnkit/generators/turnkit/install/templates/message.rb +11 -0
- data/lib/turnkit/generators/turnkit/install/templates/tool_execution.rb +10 -0
- data/lib/turnkit/generators/turnkit/install/templates/turn.rb +14 -0
- data/lib/turnkit/generators/turnkit/install_generator.rb +44 -0
- data/lib/turnkit/id.rb +19 -0
- data/lib/turnkit/memory_store.rb +130 -0
- data/lib/turnkit/message.rb +67 -0
- data/lib/turnkit/message_projection.rb +27 -0
- data/lib/turnkit/rails/railtie.rb +9 -0
- data/lib/turnkit/record.rb +116 -0
- data/lib/turnkit/result.rb +19 -0
- data/lib/turnkit/skill.rb +23 -0
- data/lib/turnkit/store.rb +24 -0
- data/lib/turnkit/stores/active_record_store.rb +190 -0
- data/lib/turnkit/sub_agent_tool.rb +38 -0
- data/lib/turnkit/tool.rb +63 -0
- data/lib/turnkit/tool_call.rb +28 -0
- data/lib/turnkit/tool_execution.rb +28 -0
- data/lib/turnkit/tool_runner.rb +90 -0
- data/lib/turnkit/turn.rb +142 -0
- data/lib/turnkit/usage.rb +28 -0
- data/lib/turnkit/version.rb +5 -0
- data/lib/turnkit.rb +56 -0
- 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
|
+
[](https://rubygems.org/gems/turnkit)
|
|
4
|
+
[](https://github.com/samuelcouch/turnkit/actions)
|
|
5
|
+
[](https://www.ruby-lang.org)
|
|
6
|
+
[](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,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,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
|