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
|
@@ -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
|
data/lib/turnkit/tool.rb
ADDED
|
@@ -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
|
data/lib/turnkit/turn.rb
ADDED
|
@@ -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
|
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
|