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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class ActiveRecordStore < Store
5
+ def create_conversation(attributes)
6
+ record = conversation_class.create!(record_attributes(attributes, id_key: "uid"))
7
+ conversation_hash(record)
8
+ end
9
+
10
+ def load_conversation(id)
11
+ conversation_hash(conversation_class.find_by!(uid: id))
12
+ end
13
+
14
+ def next_message_sequence(conversation_id)
15
+ conversation_class.transaction do
16
+ conversation = conversation_class.lock.find_by!(uid: conversation_id)
17
+ message_class.where(conversation_uid: conversation.uid).maximum(:sequence).to_i + 1
18
+ end
19
+ end
20
+
21
+ def latest_message_sequence(conversation_id)
22
+ message_class.where(conversation_uid: conversation_id).maximum(:sequence).to_i
23
+ end
24
+
25
+ def append_message(attributes)
26
+ attrs = attributes.transform_keys(&:to_s)
27
+ sequence = nil
28
+ message = nil
29
+ record = conversation_class.transaction do
30
+ conversation_class.lock.find_by!(uid: attrs.fetch("conversation_id"))
31
+ sequence = message_class.where(conversation_uid: attrs.fetch("conversation_id")).maximum(:sequence).to_i + 1
32
+ message = Record.message(attrs.merge("sequence" => sequence))
33
+ message_class.create!(
34
+ uid: message.fetch("id"),
35
+ conversation_uid: message.fetch("conversation_id"),
36
+ turn_uid: message["turn_id"],
37
+ role: message.fetch("role"),
38
+ kind: message.fetch("kind"),
39
+ sequence: message.fetch("sequence"),
40
+ content: message.fetch("content"),
41
+ text: message.fetch("text"),
42
+ tool_execution_uid: message["tool_execution_id"],
43
+ provider_message_id: message["provider_message_id"],
44
+ metadata: message.fetch("metadata")
45
+ )
46
+ end
47
+ message_hash(record)
48
+ end
49
+
50
+ def list_messages(conversation_id, through_sequence: nil, turn_id: nil)
51
+ scope = message_class.where(conversation_uid: conversation_id)
52
+ if through_sequence
53
+ scope = scope.where("sequence <= ? OR turn_uid = ?", through_sequence, turn_id)
54
+ end
55
+ scope.order(:sequence, :created_at, :uid).map { |record| message_hash(record) }
56
+ end
57
+
58
+ def create_turn(attributes)
59
+ attrs = Record.turn(attributes)
60
+ record = turn_class.create!(
61
+ uid: attrs.fetch("id"),
62
+ conversation_uid: attrs.fetch("conversation_id"),
63
+ agent_name: attrs["agent_name"],
64
+ parent_turn_uid: attrs["parent_turn_id"],
65
+ parent_tool_execution_uid: attrs["parent_tool_execution_id"],
66
+ root_turn_uid: attrs.fetch("root_turn_id"),
67
+ context_message_sequence: attrs["context_message_sequence"].to_i,
68
+ status: attrs.fetch("status"),
69
+ model: attrs["model"],
70
+ options: attrs["options"] || {},
71
+ usage: attrs["usage"] || {},
72
+ cost: attrs["cost"],
73
+ error: attrs["error"],
74
+ output_text: attrs["output_text"],
75
+ started_at: attrs["started_at"],
76
+ heartbeat_at: attrs["heartbeat_at"],
77
+ completed_at: attrs["completed_at"]
78
+ )
79
+ turn_hash(record)
80
+ end
81
+
82
+ def load_turn(id)
83
+ turn_hash(turn_class.find_by!(uid: id))
84
+ end
85
+
86
+ def update_turn(id, attributes)
87
+ record = turn_class.find_by!(uid: id)
88
+ record.update!(Record.turn_update(attributes))
89
+ turn_hash(record)
90
+ end
91
+
92
+ def list_turns(root_turn_id: nil, conversation_id: nil)
93
+ scope = turn_class.all
94
+ scope = scope.where(root_turn_uid: root_turn_id) if root_turn_id
95
+ scope = scope.where(conversation_uid: conversation_id) if conversation_id
96
+ scope.order(:created_at, :uid).map { |record| turn_hash(record) }
97
+ end
98
+
99
+ def create_tool_execution(attributes)
100
+ attrs = Record.tool_execution(attributes)
101
+ record = tool_execution_class.create!(
102
+ uid: attrs.fetch("id"),
103
+ turn_uid: attrs.fetch("turn_id"),
104
+ tool_call_id: attrs.fetch("tool_call_id"),
105
+ tool_name: attrs.fetch("tool_name"),
106
+ status: attrs.fetch("status"),
107
+ arguments: attrs["arguments"] || {},
108
+ result: attrs["result"],
109
+ error: attrs["error"],
110
+ started_at: attrs["started_at"],
111
+ completed_at: attrs["completed_at"]
112
+ )
113
+ tool_execution_hash(record)
114
+ end
115
+
116
+ def load_tool_execution(id)
117
+ tool_execution_hash(tool_execution_class.find_by!(uid: id))
118
+ end
119
+
120
+ def update_tool_execution(id, attributes)
121
+ record = tool_execution_class.find_by!(uid: id)
122
+ record.update!(Record.tool_execution_update(attributes))
123
+ tool_execution_hash(record)
124
+ end
125
+
126
+ def list_tool_executions(turn_id:)
127
+ tool_execution_class.where(turn_uid: turn_id).order(:created_at, :uid).map { |record| tool_execution_hash(record) }
128
+ end
129
+
130
+ def find_stale_turns(before:)
131
+ turn_class.where(status: %w[pending running]).where("COALESCE(heartbeat_at, started_at, created_at) < ?", before).map { |record| turn_hash(record) }
132
+ end
133
+
134
+ private
135
+ def conversation_class = constantize(TurnKit.conversation_record_class || "Turnkit::Conversation")
136
+ def turn_class = constantize(TurnKit.turn_record_class || "Turnkit::Turn")
137
+ def message_class = constantize(TurnKit.message_record_class || "Turnkit::Message")
138
+ def tool_execution_class = constantize(TurnKit.tool_execution_record_class || "Turnkit::ToolExecution")
139
+
140
+ def constantize(name)
141
+ name.to_s.split("::").inject(Object) { |mod, part| mod.const_get(part) }
142
+ end
143
+
144
+ def record_attributes(attributes, id_key:)
145
+ attrs = Record.conversation(attributes)
146
+ subject_type, subject_id = Record.subject_pair(attrs["subject"])
147
+ {
148
+ id_key => attrs.fetch("id"),
149
+ agent_name: attrs["agent_name"],
150
+ model: attrs["model"],
151
+ subject_type: subject_type,
152
+ subject_id: subject_id,
153
+ metadata: attrs["metadata"] || {}
154
+ }
155
+ end
156
+
157
+ def conversation_hash(record)
158
+ { "id" => record.uid, "agent_name" => record.agent_name, "model" => record.model, "metadata" => record.metadata || {}, "created_at" => record.created_at, "updated_at" => record.updated_at }
159
+ end
160
+
161
+ def turn_hash(record)
162
+ {
163
+ "id" => record.uid, "conversation_id" => record.conversation_uid, "agent_name" => record.agent_name,
164
+ "parent_turn_id" => record.parent_turn_uid, "parent_tool_execution_id" => record.parent_tool_execution_uid,
165
+ "root_turn_id" => record.root_turn_uid, "context_message_sequence" => record.context_message_sequence,
166
+ "status" => record.status, "model" => record.model, "options" => record.options || {}, "usage" => record.usage || {},
167
+ "cost" => record.cost, "error" => record.error, "output_text" => record.output_text,
168
+ "started_at" => record.started_at, "heartbeat_at" => record.heartbeat_at, "completed_at" => record.completed_at,
169
+ "created_at" => record.created_at, "updated_at" => record.updated_at
170
+ }
171
+ end
172
+
173
+ def message_hash(record)
174
+ {
175
+ "id" => record.uid, "conversation_id" => record.conversation_uid, "turn_id" => record.turn_uid,
176
+ "role" => record.role, "kind" => record.kind, "sequence" => record.sequence, "content" => record.content,
177
+ "text" => record.text, "tool_execution_id" => record.tool_execution_uid,
178
+ "provider_message_id" => record.provider_message_id, "metadata" => record.metadata || {}, "created_at" => record.created_at
179
+ }
180
+ end
181
+
182
+ def tool_execution_hash(record)
183
+ {
184
+ "id" => record.uid, "turn_id" => record.turn_uid, "tool_call_id" => record.tool_call_id,
185
+ "tool_name" => record.tool_name, "status" => record.status, "arguments" => record.arguments || {},
186
+ "result" => record.result, "error" => record.error, "started_at" => record.started_at, "completed_at" => record.completed_at
187
+ }
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class SubAgentTool < Tool
5
+ parameter :task, :string, required: true, description: "The task for the sub-agent to complete."
6
+ parameter :context, :string, required: false, description: "Relevant context for the sub-agent."
7
+
8
+ def self.for(agent)
9
+ Class.new(self) do
10
+ @agent = agent
11
+ tool_name agent.name
12
+ description agent.description.empty? ? "Delegate work to #{agent.name}." : agent.description
13
+
14
+ class << self
15
+ attr_reader :agent
16
+ end
17
+ end
18
+ end
19
+
20
+ def call(task:, context: nil, turnkit_context:)
21
+ sub_agent = self.class.agent
22
+ parent_turn = turnkit_context.turn
23
+ conversation = parent_turn.conversation
24
+ prompt = [ task, context ].compact.join("\n\n")
25
+ trigger = conversation.append_message(role: "user", kind: "text", text: prompt, turn_id: parent_turn.id)
26
+ child = conversation.run!(
27
+ trigger_message_id: trigger.id,
28
+ budget: parent_turn.budget,
29
+ parent_turn: parent_turn,
30
+ parent_tool_execution: turnkit_context.execution,
31
+ depth: parent_turn.depth + 1,
32
+ model: sub_agent.effective_model,
33
+ agent: sub_agent
34
+ )
35
+ { "turn_id" => child.id, "status" => child.status, "result" => child.output_text }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Tool
5
+ TYPES = %i[string integer number boolean array object enum].freeze
6
+
7
+ class << self
8
+ def tool_name(value = nil)
9
+ @tool_name = value.to_s if value
10
+ @tool_name ||= name.to_s.split("::").last.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
11
+ end
12
+
13
+ def description(value = nil)
14
+ @description = value.to_s if value
15
+ @description.to_s
16
+ end
17
+
18
+ def parameter(name, type = :string, required: false, description: "", default: nil, enum: nil)
19
+ raise ArgumentError, "unknown parameter type: #{type}" unless TYPES.include?(type)
20
+
21
+ parameters << {
22
+ name: name.to_s,
23
+ type: type,
24
+ required: required ? true : false,
25
+ description: description.to_s,
26
+ default: default,
27
+ enum: enum
28
+ }.compact
29
+ end
30
+
31
+ def parameters
32
+ @parameters ||= superclass.respond_to?(:parameters) ? superclass.parameters.dup : []
33
+ end
34
+
35
+ def ends_turn?
36
+ false
37
+ end
38
+
39
+ def completion_message(_result)
40
+ nil
41
+ end
42
+
43
+ def call(arguments = {}, context:)
44
+ keyword_arguments = symbolize(arguments)
45
+ instance = new
46
+ if accepts_turnkit_context?(instance)
47
+ instance.call(**keyword_arguments, turnkit_context: context)
48
+ else
49
+ instance.call(**keyword_arguments, context: context)
50
+ end
51
+ end
52
+
53
+ private
54
+ def accepts_turnkit_context?(instance)
55
+ instance.method(:call).parameters.any? { |kind, name| %i[key keyreq].include?(kind) && name == :turnkit_context }
56
+ end
57
+
58
+ def symbolize(hash)
59
+ hash.transform_keys(&:to_sym)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class ToolCall
5
+ attr_reader :id, :name, :arguments
6
+
7
+ def initialize(id:, name:, arguments: {})
8
+ @id = id.to_s
9
+ @name = name.to_s
10
+ @arguments = normalize_arguments(arguments)
11
+ end
12
+
13
+ private
14
+ def normalize_arguments(value)
15
+ case value
16
+ when Hash
17
+ value.transform_keys(&:to_s)
18
+ when String
19
+ parsed = JSON.parse(value)
20
+ parsed.is_a?(Hash) ? parsed.transform_keys(&:to_s) : {}
21
+ else
22
+ {}
23
+ end
24
+ rescue JSON::ParserError
25
+ {}
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class ToolExecution
5
+ STATUSES = Record::TOOL_EXECUTION_STATUSES
6
+
7
+ attr_reader :id, :turn_id, :tool_call_id, :tool_name, :status
8
+ attr_reader :arguments, :result, :error, :started_at, :completed_at
9
+
10
+ def initialize(attributes = {})
11
+ attrs = attributes.transform_keys(&:to_s)
12
+ @id = attrs.fetch("id")
13
+ @turn_id = attrs.fetch("turn_id")
14
+ @tool_call_id = attrs.fetch("tool_call_id")
15
+ @tool_name = attrs.fetch("tool_name")
16
+ @status = attrs.fetch("status")
17
+ @arguments = attrs["arguments"] || {}
18
+ @result = attrs["result"]
19
+ @error = attrs["error"]
20
+ @started_at = attrs["started_at"]
21
+ @completed_at = attrs["completed_at"]
22
+ end
23
+
24
+ STATUSES.each do |state|
25
+ define_method("#{state}?") { status == state }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class ToolRunner
5
+ def initialize(turn)
6
+ @turn = turn
7
+ end
8
+
9
+ def dispatch(tool_calls)
10
+ tool_calls.each do |tool_call|
11
+ execution = run(tool_call)
12
+ return execution if execution.completed? && tool_class(tool_call.name)&.ends_turn?
13
+ end
14
+ nil
15
+ end
16
+
17
+ def completion_message(execution)
18
+ tool = tool_class(execution.tool_name)
19
+ tool.completion_message(execution.result) || execution.result&.fetch("result", nil) || "Completed via #{execution.tool_name}."
20
+ end
21
+
22
+ private
23
+ attr_reader :turn
24
+
25
+ def run(tool_call)
26
+ turn.budget.count_tool_execution!
27
+ tool = tool_class(tool_call.name)
28
+ execution = ToolExecution.new(create_execution(tool_call))
29
+
30
+ unless tool
31
+ return finish_error(execution, tool_call, "unknown tool: #{tool_call.name}")
32
+ end
33
+
34
+ context = ToolContext.new(turn: turn, execution: execution)
35
+ payload = begin
36
+ normalize_payload(tool.call(tool_call.arguments, context: context))
37
+ rescue StandardError => error
38
+ return finish_error(execution, tool_call, error.message, details: { "class" => error.class.name })
39
+ end
40
+ finish_success(execution, tool_call, payload)
41
+ end
42
+
43
+ def create_execution(tool_call)
44
+ turn.store.create_tool_execution(
45
+ "turn_id" => turn.id,
46
+ "tool_call_id" => tool_call.id,
47
+ "tool_name" => tool_call.name,
48
+ "status" => "running",
49
+ "arguments" => tool_call.arguments,
50
+ "started_at" => Clock.now
51
+ )
52
+ end
53
+
54
+ def finish_success(execution, tool_call, payload)
55
+ attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
56
+ append_result(execution, tool_call, payload)
57
+ ToolExecution.new(attrs)
58
+ end
59
+
60
+ def finish_error(execution, tool_call, message, details: nil)
61
+ error = { "message" => message.to_s, "details" => details }.compact
62
+ attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
63
+ append_result(execution, tool_call, error)
64
+ ToolExecution.new(attrs)
65
+ end
66
+
67
+ def append_result(execution, tool_call, payload)
68
+ turn.conversation.append_message(
69
+ role: "tool",
70
+ kind: "tool_result",
71
+ text: payload.to_json,
72
+ turn_id: turn.id,
73
+ tool_execution_id: execution.id,
74
+ metadata: { "tool_call_id" => tool_call.id, "tool_name" => tool_call.name }
75
+ )
76
+ end
77
+
78
+ def tool_class(name)
79
+ turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
80
+ end
81
+
82
+ def normalize_payload(value)
83
+ case value
84
+ when Hash then value.transform_keys(&:to_s)
85
+ when Array then { "items" => value }
86
+ else { "result" => value.to_s }
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Turn
5
+ STATUSES = Record::TURN_STATUSES
6
+
7
+ attr_reader :agent, :conversation, :store, :budget, :depth
8
+ attr_reader :id, :conversation_id, :agent_name, :parent_turn_id, :parent_tool_execution_id
9
+ attr_reader :root_turn_id, :context_message_sequence, :model
10
+
11
+ def initialize(agent:, conversation:, record:, store:, budget: nil, depth: 0)
12
+ @agent = agent
13
+ @conversation = conversation
14
+ @store = store
15
+ @record = record.transform_keys(&:to_s)
16
+ @id = @record.fetch("id")
17
+ @conversation_id = @record.fetch("conversation_id")
18
+ @agent_name = @record["agent_name"]
19
+ @parent_turn_id = @record["parent_turn_id"]
20
+ @parent_tool_execution_id = @record["parent_tool_execution_id"]
21
+ @root_turn_id = @record["root_turn_id"] || id
22
+ @context_message_sequence = @record["context_message_sequence"].to_i
23
+ @model = @record["model"] || agent.effective_model
24
+ @budget = budget || agent.build_budget
25
+ @depth = depth
26
+ end
27
+
28
+ def run!
29
+ return self unless status == "pending"
30
+
31
+ update!(status: "running", started_at: Clock.now, heartbeat_at: Clock.now)
32
+ loop do
33
+ budget.check!(depth: depth)
34
+ budget.count_iteration!
35
+
36
+ result = agent.effective_client.chat(
37
+ model: model,
38
+ messages: llm_messages,
39
+ tools: agent.effective_tools,
40
+ instructions: agent.instructions_with_skills,
41
+ metadata: { turn_id: id, conversation_id: conversation.id }
42
+ )
43
+
44
+ budget.add_usage!(result.usage)
45
+ add_usage!(result.usage)
46
+ persist_assistant_message(result)
47
+
48
+ if result.tool_calls?
49
+ runner = ToolRunner.new(self)
50
+ terminal = runner.dispatch(result.tool_calls)
51
+ if terminal
52
+ complete_from_terminal_tool(runner, terminal)
53
+ break
54
+ end
55
+ else
56
+ update!(status: "completed", output_text: result.text, completed_at: Clock.now)
57
+ break
58
+ end
59
+ end
60
+ reload
61
+ self
62
+ rescue StandardError => error
63
+ update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
64
+ reload
65
+ self
66
+ end
67
+
68
+ def status
69
+ @record.fetch("status")
70
+ end
71
+
72
+ STATUSES.each do |state|
73
+ define_method("#{state}?") { status == state }
74
+ end
75
+
76
+ def output_text
77
+ @record["output_text"].to_s
78
+ end
79
+
80
+ def tool_executions
81
+ store.list_tool_executions(turn_id: id).map { |attrs| ToolExecution.new(attrs) }
82
+ end
83
+
84
+ def reload
85
+ @record = store.load_turn(id)
86
+ self
87
+ end
88
+
89
+ def stale!
90
+ update!(status: "stale", completed_at: Clock.now)
91
+ end
92
+
93
+ private
94
+ def llm_messages
95
+ MessageProjection.for(conversation.messages_for_turn(self))
96
+ end
97
+
98
+ def persist_assistant_message(result)
99
+ if result.tool_calls?
100
+ conversation.append_message(
101
+ role: "assistant",
102
+ kind: "tool_call",
103
+ text: result.text,
104
+ turn_id: id,
105
+ metadata: { "tool_calls" => result.tool_calls.map { |call| { "id" => call.id, "name" => call.name, "arguments" => call.arguments } } }
106
+ )
107
+ else
108
+ conversation.append_message(role: "assistant", kind: "text", text: result.text, turn_id: id)
109
+ end
110
+ end
111
+
112
+ def complete_from_terminal_tool(runner, execution)
113
+ message = runner.completion_message(execution)
114
+ conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
115
+ update!(status: "completed", output_text: message, completed_at: Clock.now)
116
+ end
117
+
118
+ def add_usage!(usage)
119
+ current = @record["usage"] || {}
120
+ totals = {
121
+ "input_tokens" => current["input_tokens"].to_i + usage.input_tokens,
122
+ "output_tokens" => current["output_tokens"].to_i + usage.output_tokens,
123
+ "cached_tokens" => current["cached_tokens"].to_i + usage.cached_tokens,
124
+ "total_tokens" => current["total_tokens"].to_i + usage.total_tokens
125
+ }
126
+ update!(usage: totals, heartbeat_at: Clock.now)
127
+ end
128
+
129
+ def update!(attributes)
130
+ @record = store.update_turn(id, attributes)
131
+ end
132
+ end
133
+
134
+ class ToolContext
135
+ attr_reader :turn, :execution
136
+
137
+ def initialize(turn:, execution:)
138
+ @turn = turn
139
+ @execution = execution
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ class Usage
5
+ attr_reader :input_tokens, :output_tokens, :cached_tokens, :cost
6
+
7
+ def initialize(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cost: nil)
8
+ @input_tokens = input_tokens.to_i
9
+ @output_tokens = output_tokens.to_i
10
+ @cached_tokens = cached_tokens.to_i
11
+ @cost = cost
12
+ end
13
+
14
+ def total_tokens
15
+ input_tokens + output_tokens + cached_tokens
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ "input_tokens" => input_tokens,
21
+ "output_tokens" => output_tokens,
22
+ "cached_tokens" => cached_tokens,
23
+ "total_tokens" => total_tokens,
24
+ "cost" => cost
25
+ }.compact
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ VERSION = "0.1.0"
5
+ end
data/lib/turnkit.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ require_relative "turnkit/version"
8
+ require_relative "turnkit/error"
9
+ require_relative "turnkit/id"
10
+ require_relative "turnkit/clock"
11
+ require_relative "turnkit/budget"
12
+ require_relative "turnkit/agent"
13
+ require_relative "turnkit/client"
14
+ require_relative "turnkit/conversation"
15
+ require_relative "turnkit/message"
16
+ require_relative "turnkit/record"
17
+ require_relative "turnkit/result"
18
+ require_relative "turnkit/skill"
19
+ require_relative "turnkit/store"
20
+ require_relative "turnkit/memory_store"
21
+ require_relative "turnkit/tool"
22
+ require_relative "turnkit/tool_call"
23
+ require_relative "turnkit/tool_execution"
24
+ require_relative "turnkit/sub_agent_tool"
25
+ require_relative "turnkit/message_projection"
26
+ require_relative "turnkit/tool_runner"
27
+ require_relative "turnkit/turn"
28
+ require_relative "turnkit/usage"
29
+ require_relative "turnkit/adapters/ruby_llm"
30
+ require_relative "turnkit/stores/active_record_store"
31
+
32
+ require_relative "turnkit/rails/railtie" if defined?(Rails)
33
+
34
+ module TurnKit
35
+ class << self
36
+ attr_accessor :default_model, :client, :store, :logger
37
+ attr_accessor :max_iterations, :timeout, :max_depth, :max_tool_executions
38
+ attr_accessor :cost_limit
39
+ attr_accessor :conversation_record_class, :turn_record_class
40
+ attr_accessor :message_record_class, :tool_execution_record_class
41
+ end
42
+
43
+ self.default_model = "claude-sonnet-4-5"
44
+ self.store = MemoryStore.new
45
+ self.client = Adapters::RubyLLM.new
46
+ self.max_iterations = 25
47
+ self.timeout = 300
48
+ self.max_depth = 3
49
+ self.max_tool_executions = 100
50
+
51
+ def self.reconcile_stale!(before: Clock.now - (timeout || 300))
52
+ store.find_stale_turns(before: before).each do |turn|
53
+ store.update_turn(turn.fetch("id"), "status" => "stale", "completed_at" => Clock.now)
54
+ end
55
+ end
56
+ end