turnkit 0.2.5 → 0.2.7
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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +155 -321
- data/lib/turnkit/adapters/ruby_llm.rb +69 -5
- data/lib/turnkit/agent.rb +20 -2
- data/lib/turnkit/client.rb +5 -1
- data/lib/turnkit/compaction.rb +406 -0
- data/lib/turnkit/conversation.rb +11 -4
- data/lib/turnkit/error.rb +3 -0
- data/lib/turnkit/event.rb +25 -0
- data/lib/turnkit/generators/turnkit/install/templates/create_turnkit_tables.rb +1 -0
- data/lib/turnkit/generators/turnkit/install/templates/initializer.rb +6 -0
- data/lib/turnkit/generators/turnkit/install_generator.rb +6 -0
- data/lib/turnkit/message.rb +21 -1
- data/lib/turnkit/message_projection.rb +28 -1
- data/lib/turnkit/model_request.rb +35 -0
- data/lib/turnkit/record.rb +2 -1
- data/lib/turnkit/result.rb +3 -2
- data/lib/turnkit/stores/active_record_store.rb +14 -4
- data/lib/turnkit/sub_agent_tool.rb +13 -4
- data/lib/turnkit/tool.rb +117 -4
- data/lib/turnkit/tool_call.rb +3 -1
- data/lib/turnkit/tool_runner.rb +8 -1
- data/lib/turnkit/turn.rb +101 -16
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +7 -0
- metadata +8 -6
|
@@ -25,6 +25,12 @@ module TurnKit
|
|
|
25
25
|
template "tool_execution.rb", "app/models/turnkit/tool_execution.rb"
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def create_ai_directories
|
|
29
|
+
empty_directory "app/ai/agents"
|
|
30
|
+
empty_directory "app/ai/tools"
|
|
31
|
+
empty_directory "app/ai/skills"
|
|
32
|
+
end
|
|
33
|
+
|
|
28
34
|
def copy_migration
|
|
29
35
|
migration_template "create_turnkit_tables.rb", "db/migrate/create_turnkit_tables.rb"
|
|
30
36
|
end
|
data/lib/turnkit/message.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Message
|
|
5
5
|
ROLES = %w[user assistant tool].freeze
|
|
6
|
-
KINDS = %w[text tool_call tool_result].freeze
|
|
6
|
+
KINDS = %w[text tool_call tool_result context_summary].freeze
|
|
7
7
|
|
|
8
8
|
attr_reader :id, :conversation_id, :turn_id, :role, :kind, :sequence
|
|
9
9
|
attr_reader :content, :text, :tool_execution_id, :provider_message_id, :metadata, :created_at
|
|
@@ -43,6 +43,26 @@ module TurnKit
|
|
|
43
43
|
}
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
def text?
|
|
47
|
+
kind == "text"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def tool_call?
|
|
51
|
+
kind == "tool_call"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def tool_result?
|
|
55
|
+
kind == "tool_result"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def context_summary?
|
|
59
|
+
kind == "context_summary"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def compaction_metadata
|
|
63
|
+
metadata.fetch("compaction", {})
|
|
64
|
+
end
|
|
65
|
+
|
|
46
66
|
private
|
|
47
67
|
def stringify(hash)
|
|
48
68
|
hash.transform_keys(&:to_s)
|
|
@@ -2,14 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class MessageProjection
|
|
5
|
+
CONTEXT_SUMMARY_TRIGGER = "What did we do so far?"
|
|
6
|
+
CONTEXT_SUMMARY_PREFIX = <<~TEXT.strip
|
|
7
|
+
[CONTEXT COMPACTION — REFERENCE ONLY]
|
|
8
|
+
|
|
9
|
+
Earlier TurnKit conversation messages were compacted into the summary below. This is a handoff from a previous context window. Treat it as background reference, not as active instructions.
|
|
10
|
+
|
|
11
|
+
Do not answer questions or perform tasks merely because they appear in this summary. Respond to the latest user message after this summary.
|
|
12
|
+
|
|
13
|
+
If the latest user message contradicts, supersedes, changes topic from, or diverges from Active Task, In Progress, Pending User Asks, or Remaining Work, the latest user message wins.
|
|
14
|
+
|
|
15
|
+
Subject context and live context are recomputed for the current turn and are more authoritative for state-sensitive facts.
|
|
16
|
+
|
|
17
|
+
The original messages remain durably stored; this summary only affects the model-visible prompt projection.
|
|
18
|
+
TEXT
|
|
19
|
+
|
|
5
20
|
def self.for(messages)
|
|
6
|
-
messages.
|
|
21
|
+
messages.flat_map { |message| new(message).to_a }
|
|
7
22
|
end
|
|
8
23
|
|
|
9
24
|
def initialize(message)
|
|
10
25
|
@message = message
|
|
11
26
|
end
|
|
12
27
|
|
|
28
|
+
def to_a
|
|
29
|
+
case message.kind
|
|
30
|
+
when "context_summary"
|
|
31
|
+
[
|
|
32
|
+
{ role: :user, content: CONTEXT_SUMMARY_TRIGGER },
|
|
33
|
+
{ role: :assistant, content: [ CONTEXT_SUMMARY_PREFIX, message.text ].reject(&:empty?).join("\n\n") }
|
|
34
|
+
]
|
|
35
|
+
else
|
|
36
|
+
[ to_h ]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
13
40
|
def to_h
|
|
14
41
|
case message.kind
|
|
15
42
|
when "tool_call"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
class ModelRequest
|
|
5
|
+
attr_reader :model, :messages, :tools, :instructions, :thinking, :output_schema, :metadata, :report
|
|
6
|
+
|
|
7
|
+
def initialize(model:, messages:, tools:, instructions:, thinking: nil, output_schema: nil, metadata: {}, report: nil)
|
|
8
|
+
@model = model
|
|
9
|
+
@messages = Array(messages)
|
|
10
|
+
@tools = Array(tools)
|
|
11
|
+
@instructions = instructions.to_s
|
|
12
|
+
@thinking = thinking
|
|
13
|
+
@output_schema = output_schema
|
|
14
|
+
@metadata = metadata || {}
|
|
15
|
+
@report = report || {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def tool_names
|
|
19
|
+
tools.map(&:tool_name)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_h
|
|
23
|
+
{
|
|
24
|
+
"model" => model,
|
|
25
|
+
"messages" => messages,
|
|
26
|
+
"tools" => tool_names,
|
|
27
|
+
"instructions" => instructions,
|
|
28
|
+
"thinking" => thinking,
|
|
29
|
+
"output_schema" => output_schema,
|
|
30
|
+
"metadata" => metadata,
|
|
31
|
+
"report" => report
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/turnkit/record.rb
CHANGED
|
@@ -5,7 +5,7 @@ module TurnKit
|
|
|
5
5
|
TURN_STATUSES = %w[pending running completed failed cancelled stale].freeze
|
|
6
6
|
TOOL_EXECUTION_STATUSES = %w[pending running completed failed cancelled].freeze
|
|
7
7
|
|
|
8
|
-
TURN_UPDATE_KEYS = %w[status options usage cost error output_text started_at heartbeat_at completed_at].freeze
|
|
8
|
+
TURN_UPDATE_KEYS = %w[status options usage cost error output_text output_data started_at heartbeat_at completed_at].freeze
|
|
9
9
|
TOOL_EXECUTION_UPDATE_KEYS = %w[status result error started_at completed_at].freeze
|
|
10
10
|
|
|
11
11
|
module_function
|
|
@@ -49,6 +49,7 @@ module TurnKit
|
|
|
49
49
|
"cost" => attrs["cost"],
|
|
50
50
|
"error" => attrs["error"],
|
|
51
51
|
"output_text" => attrs["output_text"],
|
|
52
|
+
"output_data" => attrs["output_data"],
|
|
52
53
|
"started_at" => attrs["started_at"],
|
|
53
54
|
"heartbeat_at" => attrs["heartbeat_at"],
|
|
54
55
|
"completed_at" => attrs["completed_at"],
|
data/lib/turnkit/result.rb
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Result
|
|
5
|
-
attr_reader :text, :tool_calls, :usage, :model, :finish_reason
|
|
5
|
+
attr_reader :text, :tool_calls, :usage, :model, :finish_reason, :output_data
|
|
6
6
|
|
|
7
|
-
def initialize(text: "", tool_calls: [], usage: Usage.new, model: nil, finish_reason: nil)
|
|
7
|
+
def initialize(text: "", tool_calls: [], usage: Usage.new, model: nil, finish_reason: nil, output_data: nil)
|
|
8
8
|
@text = text.to_s
|
|
9
9
|
@tool_calls = Array(tool_calls)
|
|
10
10
|
@usage = usage || Usage.new
|
|
11
11
|
@model = model
|
|
12
12
|
@finish_reason = finish_reason
|
|
13
|
+
@output_data = output_data
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def tool_calls?
|
|
@@ -57,7 +57,7 @@ module TurnKit
|
|
|
57
57
|
|
|
58
58
|
def create_turn(attributes)
|
|
59
59
|
attrs = Record.turn(attributes)
|
|
60
|
-
|
|
60
|
+
record_attrs = {
|
|
61
61
|
uid: attrs.fetch("id"),
|
|
62
62
|
conversation_uid: attrs.fetch("conversation_id"),
|
|
63
63
|
agent_name: attrs["agent_name"],
|
|
@@ -75,7 +75,9 @@ module TurnKit
|
|
|
75
75
|
started_at: attrs["started_at"],
|
|
76
76
|
heartbeat_at: attrs["heartbeat_at"],
|
|
77
77
|
completed_at: attrs["completed_at"]
|
|
78
|
-
|
|
78
|
+
}
|
|
79
|
+
record_attrs[:output_data] = attrs["output_data"] if turn_has_attribute?("output_data")
|
|
80
|
+
record = turn_class.create!(record_attrs)
|
|
79
81
|
turn_hash(record)
|
|
80
82
|
end
|
|
81
83
|
|
|
@@ -85,7 +87,9 @@ module TurnKit
|
|
|
85
87
|
|
|
86
88
|
def update_turn(id, attributes)
|
|
87
89
|
record = turn_class.find_by!(uid: id)
|
|
88
|
-
|
|
90
|
+
attrs = Record.turn_update(attributes)
|
|
91
|
+
attrs.delete("output_data") unless turn_has_attribute?("output_data")
|
|
92
|
+
record.update!(attrs)
|
|
89
93
|
turn_hash(record)
|
|
90
94
|
end
|
|
91
95
|
|
|
@@ -160,7 +164,7 @@ module TurnKit
|
|
|
160
164
|
end
|
|
161
165
|
|
|
162
166
|
def turn_hash(record)
|
|
163
|
-
{
|
|
167
|
+
attrs = {
|
|
164
168
|
"id" => record.uid, "conversation_id" => record.conversation_uid, "agent_name" => record.agent_name,
|
|
165
169
|
"parent_turn_id" => record.parent_turn_uid, "parent_tool_execution_id" => record.parent_tool_execution_uid,
|
|
166
170
|
"root_turn_id" => record.root_turn_uid, "context_message_sequence" => record.context_message_sequence,
|
|
@@ -169,6 +173,12 @@ module TurnKit
|
|
|
169
173
|
"started_at" => record.started_at, "heartbeat_at" => record.heartbeat_at, "completed_at" => record.completed_at,
|
|
170
174
|
"created_at" => record.created_at, "updated_at" => record.updated_at
|
|
171
175
|
}
|
|
176
|
+
attrs["output_data"] = record.output_data if record.respond_to?(:output_data)
|
|
177
|
+
attrs
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def turn_has_attribute?(name)
|
|
181
|
+
turn_class.respond_to?(:attribute_names) && turn_class.attribute_names.include?(name)
|
|
172
182
|
end
|
|
173
183
|
|
|
174
184
|
def message_hash(record)
|
|
@@ -21,9 +21,17 @@ module TurnKit
|
|
|
21
21
|
def call(task:, context: nil, turnkit_context:)
|
|
22
22
|
sub_agent = self.class.agent
|
|
23
23
|
parent_turn = turnkit_context.turn
|
|
24
|
-
conversation = parent_turn.conversation
|
|
25
24
|
prompt = [ task, context ].compact.join("\n\n")
|
|
26
|
-
|
|
25
|
+
conversation = sub_agent.conversation(metadata: {
|
|
26
|
+
"parent_conversation_id" => parent_turn.conversation.id,
|
|
27
|
+
"parent_turn_id" => parent_turn.id,
|
|
28
|
+
"parent_tool_execution_id" => turnkit_context.execution.id
|
|
29
|
+
})
|
|
30
|
+
trigger = conversation.say(prompt, metadata: {
|
|
31
|
+
"parent_conversation_id" => parent_turn.conversation.id,
|
|
32
|
+
"parent_turn_id" => parent_turn.id,
|
|
33
|
+
"parent_tool_execution_id" => turnkit_context.execution.id
|
|
34
|
+
})
|
|
27
35
|
child = conversation.run!(
|
|
28
36
|
trigger_message_id: trigger.id,
|
|
29
37
|
budget: parent_turn.budget,
|
|
@@ -31,9 +39,10 @@ module TurnKit
|
|
|
31
39
|
parent_tool_execution: turnkit_context.execution,
|
|
32
40
|
depth: parent_turn.depth + 1,
|
|
33
41
|
model: sub_agent.effective_model,
|
|
34
|
-
agent: sub_agent
|
|
42
|
+
agent: sub_agent,
|
|
43
|
+
on_event: parent_turn.agent.effective_on_event
|
|
35
44
|
)
|
|
36
|
-
{ "turn_id" => child.id, "status" => child.status, "result" => child.output_text }
|
|
45
|
+
{ "conversation_id" => conversation.id, "turn_id" => child.id, "status" => child.status, "result" => child.output_text, "output_data" => child.output_data }.compact
|
|
37
46
|
end
|
|
38
47
|
end
|
|
39
48
|
end
|
data/lib/turnkit/tool.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Tool
|
|
5
5
|
TYPES = %i[string integer number boolean array object enum].freeze
|
|
6
|
+
NAME_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
6
7
|
|
|
7
8
|
class << self
|
|
8
9
|
def tool_name(value = nil)
|
|
@@ -20,16 +21,22 @@ module TurnKit
|
|
|
20
21
|
@usage_hint.to_s
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
def parameter(name, type = :string, required: false, description: "", default: nil, enum: nil)
|
|
24
|
+
def parameter(name, type = :string, required: false, description: "", default: nil, enum: nil, items: nil, properties: nil)
|
|
25
|
+
name = name.to_s
|
|
24
26
|
raise ArgumentError, "unknown parameter type: #{type}" unless TYPES.include?(type)
|
|
27
|
+
raise ArgumentError, "invalid parameter name: #{name}" unless NAME_PATTERN.match?(name)
|
|
28
|
+
raise ArgumentError, "duplicate parameter: #{name}" if parameters.any? { |param| param.fetch(:name) == name }
|
|
29
|
+
raise ArgumentError, "enum values are required for enum parameter: #{name}" if type == :enum && Array(enum).empty?
|
|
25
30
|
|
|
26
31
|
parameters << {
|
|
27
|
-
name: name
|
|
32
|
+
name: name,
|
|
28
33
|
type: type,
|
|
29
34
|
required: required ? true : false,
|
|
30
35
|
description: description.to_s,
|
|
31
36
|
default: default,
|
|
32
|
-
enum: enum
|
|
37
|
+
enum: enum,
|
|
38
|
+
items: items,
|
|
39
|
+
properties: properties
|
|
33
40
|
}.compact
|
|
34
41
|
end
|
|
35
42
|
|
|
@@ -45,8 +52,56 @@ module TurnKit
|
|
|
45
52
|
nil
|
|
46
53
|
end
|
|
47
54
|
|
|
55
|
+
def validate_definition!
|
|
56
|
+
raise ArgumentError, "tool name is required" if tool_name.empty?
|
|
57
|
+
raise ArgumentError, "invalid tool name: #{tool_name}" unless NAME_PATTERN.match?(tool_name)
|
|
58
|
+
|
|
59
|
+
parameters.each do |param|
|
|
60
|
+
type = param.fetch(:type)
|
|
61
|
+
raise ArgumentError, "unknown parameter type: #{type}" unless TYPES.include?(type)
|
|
62
|
+
raise ArgumentError, "enum values are required for enum parameter: #{param.fetch(:name)}" if type == :enum && Array(param[:enum]).empty?
|
|
63
|
+
validate_value!(param[:default], param) if param.key?(:default)
|
|
64
|
+
end
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def input_schema
|
|
69
|
+
properties = parameters.to_h { |param| [ param.fetch(:name), schema_for(param) ] }
|
|
70
|
+
required = parameters.select { |param| param.fetch(:required) }.map { |param| param.fetch(:name) }
|
|
71
|
+
{
|
|
72
|
+
"type" => "object",
|
|
73
|
+
"properties" => properties,
|
|
74
|
+
"required" => required
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_arguments(arguments)
|
|
79
|
+
attrs = arguments.respond_to?(:to_h) ? arguments.to_h.transform_keys(&:to_s) : {}
|
|
80
|
+
allowed = parameters.map { |param| param.fetch(:name) }
|
|
81
|
+
unknown = attrs.keys - allowed
|
|
82
|
+
raise ToolValidationError, "unknown argument#{unknown.length == 1 ? "" : "s"}: #{unknown.join(", ")}" if unknown.any?
|
|
83
|
+
|
|
84
|
+
normalized = {}
|
|
85
|
+
parameters.each do |param|
|
|
86
|
+
name = param.fetch(:name)
|
|
87
|
+
if attrs.key?(name)
|
|
88
|
+
value = attrs[name]
|
|
89
|
+
elsif param.key?(:default)
|
|
90
|
+
value = param[:default]
|
|
91
|
+
elsif param.fetch(:required)
|
|
92
|
+
raise ToolValidationError, "missing required argument: #{name}"
|
|
93
|
+
else
|
|
94
|
+
next
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
validate_value!(value, param)
|
|
98
|
+
normalized[name] = value
|
|
99
|
+
end
|
|
100
|
+
normalized
|
|
101
|
+
end
|
|
102
|
+
|
|
48
103
|
def call(arguments = {}, context:)
|
|
49
|
-
keyword_arguments = symbolize(arguments)
|
|
104
|
+
keyword_arguments = symbolize(validate_arguments(arguments))
|
|
50
105
|
instance = new
|
|
51
106
|
if accepts_turnkit_context?(instance)
|
|
52
107
|
instance.call(**keyword_arguments, turnkit_context: context)
|
|
@@ -56,6 +111,64 @@ module TurnKit
|
|
|
56
111
|
end
|
|
57
112
|
|
|
58
113
|
private
|
|
114
|
+
def schema_for(param)
|
|
115
|
+
schema = {
|
|
116
|
+
"type" => schema_type(param.fetch(:type)),
|
|
117
|
+
"description" => param[:description].to_s
|
|
118
|
+
}.reject { |_key, value| value.nil? || value == "" }
|
|
119
|
+
schema["enum"] = Array(param[:enum]) if param[:enum]
|
|
120
|
+
schema["default"] = param[:default] if param.key?(:default)
|
|
121
|
+
schema["items"] = normalize_items(param[:items]) if param[:items]
|
|
122
|
+
schema["properties"] = normalize_properties(param[:properties]) if param[:properties]
|
|
123
|
+
schema
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def schema_type(type)
|
|
127
|
+
type == :enum ? "string" : type.to_s
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def normalize_items(value)
|
|
131
|
+
return { "type" => value.to_s } if value.is_a?(Symbol)
|
|
132
|
+
|
|
133
|
+
stringify_schema(value)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def normalize_properties(value)
|
|
137
|
+
value.to_h.transform_keys(&:to_s).transform_values { |schema| stringify_schema(schema) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def stringify_schema(value)
|
|
141
|
+
case value
|
|
142
|
+
when Hash
|
|
143
|
+
value.transform_keys(&:to_s).transform_values { |nested| nested.is_a?(Hash) ? stringify_schema(nested) : nested }
|
|
144
|
+
else
|
|
145
|
+
{ "type" => value.to_s }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate_value!(value, param)
|
|
150
|
+
return if value.nil? && !param.fetch(:required)
|
|
151
|
+
|
|
152
|
+
case param.fetch(:type)
|
|
153
|
+
when :string, :enum
|
|
154
|
+
raise ToolValidationError, "#{param.fetch(:name)} must be a string" unless value.is_a?(String)
|
|
155
|
+
when :integer
|
|
156
|
+
raise ToolValidationError, "#{param.fetch(:name)} must be an integer" unless value.is_a?(Integer)
|
|
157
|
+
when :number
|
|
158
|
+
raise ToolValidationError, "#{param.fetch(:name)} must be a number" unless value.is_a?(Numeric)
|
|
159
|
+
when :boolean
|
|
160
|
+
raise ToolValidationError, "#{param.fetch(:name)} must be a boolean" unless value == true || value == false
|
|
161
|
+
when :array
|
|
162
|
+
raise ToolValidationError, "#{param.fetch(:name)} must be an array" unless value.is_a?(Array)
|
|
163
|
+
when :object
|
|
164
|
+
raise ToolValidationError, "#{param.fetch(:name)} must be an object" unless value.is_a?(Hash)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if param[:enum] && !Array(param[:enum]).include?(value)
|
|
168
|
+
raise ToolValidationError, "#{param.fetch(:name)} must be one of: #{Array(param[:enum]).join(", ")}"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
59
172
|
def accepts_turnkit_context?(instance)
|
|
60
173
|
instance.method(:call).parameters.any? { |kind, name| %i[key keyreq].include?(kind) && name == :turnkit_context }
|
|
61
174
|
end
|
data/lib/turnkit/tool_call.rb
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class ToolCall
|
|
5
|
-
attr_reader :id, :name, :arguments
|
|
5
|
+
attr_reader :id, :name, :arguments, :arguments_error
|
|
6
6
|
|
|
7
7
|
def initialize(id:, name:, arguments: {})
|
|
8
8
|
@id = id.to_s
|
|
9
9
|
@name = name.to_s
|
|
10
|
+
@arguments_error = nil
|
|
10
11
|
@arguments = normalize_arguments(arguments)
|
|
11
12
|
end
|
|
12
13
|
|
|
@@ -22,6 +23,7 @@ module TurnKit
|
|
|
22
23
|
{}
|
|
23
24
|
end
|
|
24
25
|
rescue JSON::ParserError
|
|
26
|
+
@arguments_error = "invalid JSON arguments"
|
|
25
27
|
{}
|
|
26
28
|
end
|
|
27
29
|
end
|
data/lib/turnkit/tool_runner.rb
CHANGED
|
@@ -31,6 +31,10 @@ module TurnKit
|
|
|
31
31
|
return finish_error(execution, tool_call, "unknown tool: #{tool_call.name}")
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
if tool_call.arguments_error
|
|
35
|
+
return finish_error(execution, tool_call, tool_call.arguments_error)
|
|
36
|
+
end
|
|
37
|
+
|
|
34
38
|
context = ToolContext.new(turn: turn, execution: execution)
|
|
35
39
|
payload = begin
|
|
36
40
|
normalize_payload(tool.call(tool_call.arguments, context: context))
|
|
@@ -54,6 +58,7 @@ module TurnKit
|
|
|
54
58
|
def finish_success(execution, tool_call, payload)
|
|
55
59
|
attrs = turn.store.update_tool_execution(execution.id, "status" => "completed", "result" => payload, "completed_at" => Clock.now)
|
|
56
60
|
append_result(execution, tool_call, payload)
|
|
61
|
+
turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name)
|
|
57
62
|
ToolExecution.new(attrs)
|
|
58
63
|
end
|
|
59
64
|
|
|
@@ -61,11 +66,12 @@ module TurnKit
|
|
|
61
66
|
error = { "message" => message.to_s, "details" => details }.compact
|
|
62
67
|
attrs = turn.store.update_tool_execution(execution.id, "status" => "failed", "error" => error, "completed_at" => Clock.now)
|
|
63
68
|
append_result(execution, tool_call, error)
|
|
69
|
+
turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error)
|
|
64
70
|
ToolExecution.new(attrs)
|
|
65
71
|
end
|
|
66
72
|
|
|
67
73
|
def append_result(execution, tool_call, payload)
|
|
68
|
-
turn.conversation.append_message(
|
|
74
|
+
message = turn.conversation.append_message(
|
|
69
75
|
role: "tool",
|
|
70
76
|
kind: "tool_result",
|
|
71
77
|
text: payload.to_json,
|
|
@@ -73,6 +79,7 @@ module TurnKit
|
|
|
73
79
|
tool_execution_id: execution.id,
|
|
74
80
|
metadata: { "tool_call_id" => tool_call.id, "tool_name" => tool_call.name }
|
|
75
81
|
)
|
|
82
|
+
turn.emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
|
|
76
83
|
end
|
|
77
84
|
|
|
78
85
|
def tool_class(name)
|
data/lib/turnkit/turn.rb
CHANGED
|
@@ -6,10 +6,10 @@ module TurnKit
|
|
|
6
6
|
|
|
7
7
|
attr_reader :agent, :conversation, :store, :budget, :depth
|
|
8
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, :thinking
|
|
9
|
+
attr_reader :root_turn_id, :context_message_sequence, :model, :thinking, :compact, :output_schema
|
|
10
10
|
attr_reader :started_at
|
|
11
11
|
|
|
12
|
-
def initialize(agent:, conversation:, record:, store:, budget: nil, depth: 0)
|
|
12
|
+
def initialize(agent:, conversation:, record:, store:, budget: nil, depth: 0, on_event: nil)
|
|
13
13
|
@agent = agent
|
|
14
14
|
@conversation = conversation
|
|
15
15
|
@store = store
|
|
@@ -23,27 +23,30 @@ module TurnKit
|
|
|
23
23
|
@context_message_sequence = @record["context_message_sequence"].to_i
|
|
24
24
|
@model = @record["model"] || agent.effective_model
|
|
25
25
|
@thinking = thinking_from_options
|
|
26
|
+
@compact = compact_from_options
|
|
27
|
+
@output_schema = output_schema_from_options
|
|
26
28
|
@started_at = @record["started_at"]
|
|
27
29
|
@budget = budget || agent.build_budget
|
|
28
30
|
@depth = depth
|
|
31
|
+
@on_event = on_event
|
|
29
32
|
end
|
|
30
33
|
|
|
31
|
-
def run!
|
|
34
|
+
def run!(&block)
|
|
35
|
+
@on_event = block if block
|
|
32
36
|
return self unless status == "pending"
|
|
33
37
|
|
|
34
38
|
update!(status: "running", started_at: Clock.now, heartbeat_at: Clock.now)
|
|
39
|
+
emit("turn.started", status: status, model: model)
|
|
40
|
+
agent.effective_client.validate!(model: model)
|
|
35
41
|
loop do
|
|
36
42
|
budget.check!(depth: depth)
|
|
37
43
|
budget.count_iteration!
|
|
44
|
+
TurnKit::Compaction.maybe_compact!(self)
|
|
38
45
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
instructions: agent.system_prompt_for(turn: self, conversation: conversation),
|
|
44
|
-
thinking: thinking,
|
|
45
|
-
metadata: { turn_id: id, conversation_id: conversation.id }
|
|
46
|
-
)
|
|
46
|
+
request = model_request
|
|
47
|
+
emit("model.requested", model: request.model, tool_names: request.tool_names)
|
|
48
|
+
result = call_client(request)
|
|
49
|
+
emit("model.completed", model: result.model || model, tool_call_count: result.tool_calls.length)
|
|
47
50
|
result_cost = Cost.from_usage(result.usage, model: result.model || model)
|
|
48
51
|
|
|
49
52
|
budget.add_cost!(result_cost.total)
|
|
@@ -58,7 +61,8 @@ module TurnKit
|
|
|
58
61
|
break
|
|
59
62
|
end
|
|
60
63
|
else
|
|
61
|
-
update!(status: "completed", output_text: result.text, completed_at: Clock.now)
|
|
64
|
+
update!(status: "completed", output_text: result.text, output_data: result.output_data, completed_at: Clock.now)
|
|
65
|
+
emit("turn.completed", status: status, output_text: result.text)
|
|
62
66
|
break
|
|
63
67
|
end
|
|
64
68
|
end
|
|
@@ -66,10 +70,15 @@ module TurnKit
|
|
|
66
70
|
self
|
|
67
71
|
rescue StandardError => error
|
|
68
72
|
update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
|
|
73
|
+
emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
|
|
69
74
|
reload
|
|
70
75
|
self
|
|
71
76
|
end
|
|
72
77
|
|
|
78
|
+
def preview
|
|
79
|
+
model_request
|
|
80
|
+
end
|
|
81
|
+
|
|
73
82
|
def status
|
|
74
83
|
@record.fetch("status")
|
|
75
84
|
end
|
|
@@ -82,6 +91,10 @@ module TurnKit
|
|
|
82
91
|
@record["output_text"].to_s
|
|
83
92
|
end
|
|
84
93
|
|
|
94
|
+
def output_data
|
|
95
|
+
@record["output_data"]
|
|
96
|
+
end
|
|
97
|
+
|
|
85
98
|
def usage
|
|
86
99
|
Usage.from_h(@record["usage"] || {})
|
|
87
100
|
end
|
|
@@ -97,6 +110,8 @@ module TurnKit
|
|
|
97
110
|
def reload
|
|
98
111
|
@record = store.load_turn(id)
|
|
99
112
|
@thinking = thinking_from_options
|
|
113
|
+
@compact = compact_from_options
|
|
114
|
+
@output_schema = output_schema_from_options
|
|
100
115
|
self
|
|
101
116
|
end
|
|
102
117
|
|
|
@@ -104,9 +119,59 @@ module TurnKit
|
|
|
104
119
|
update!(status: "stale", completed_at: Clock.now)
|
|
105
120
|
end
|
|
106
121
|
|
|
122
|
+
def emit(type, payload = {})
|
|
123
|
+
emit_event(Event.new(type: type, turn_id: id, conversation_id: conversation.id, payload: payload))
|
|
124
|
+
end
|
|
125
|
+
|
|
107
126
|
private
|
|
127
|
+
def model_request
|
|
128
|
+
prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: agent.effective_prompt_mode(turn: self))
|
|
129
|
+
instructions = case agent.system_prompt
|
|
130
|
+
when nil
|
|
131
|
+
prompt.to_s
|
|
132
|
+
when String
|
|
133
|
+
agent.system_prompt
|
|
134
|
+
else
|
|
135
|
+
agent.system_prompt.call(prompt).to_s
|
|
136
|
+
end
|
|
137
|
+
ModelRequest.new(
|
|
138
|
+
model: model,
|
|
139
|
+
messages: llm_messages,
|
|
140
|
+
tools: agent.effective_tools,
|
|
141
|
+
instructions: instructions,
|
|
142
|
+
thinking: thinking,
|
|
143
|
+
output_schema: output_schema,
|
|
144
|
+
metadata: { turn_id: id, conversation_id: conversation.id },
|
|
145
|
+
report: prompt.report
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def call_client(request)
|
|
150
|
+
kwargs = {
|
|
151
|
+
model: request.model,
|
|
152
|
+
messages: request.messages,
|
|
153
|
+
tools: request.tools,
|
|
154
|
+
instructions: request.instructions,
|
|
155
|
+
thinking: request.thinking,
|
|
156
|
+
output_schema: request.output_schema,
|
|
157
|
+
metadata: request.metadata,
|
|
158
|
+
on_event: ->(event) { emit_event(event) }
|
|
159
|
+
}
|
|
160
|
+
accepted = chat_keyword_names(agent.effective_client)
|
|
161
|
+
kwargs = kwargs.slice(*accepted) unless accepted.include?(:keyrest)
|
|
162
|
+
agent.effective_client.chat(**kwargs)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def chat_keyword_names(client)
|
|
166
|
+
client.method(:chat).parameters.filter_map do |kind, name|
|
|
167
|
+
return [ :keyrest ] if kind == :keyrest
|
|
168
|
+
|
|
169
|
+
name if %i[key keyreq].include?(kind)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
108
173
|
def llm_messages
|
|
109
|
-
MessageProjection.for(conversation.messages_for_turn(self))
|
|
174
|
+
MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
|
|
110
175
|
end
|
|
111
176
|
|
|
112
177
|
def thinking_from_options
|
|
@@ -116,24 +181,39 @@ module TurnKit
|
|
|
116
181
|
agent.effective_thinking
|
|
117
182
|
end
|
|
118
183
|
|
|
184
|
+
def compact_from_options
|
|
185
|
+
options = (@record["options"] || {}).transform_keys(&:to_s)
|
|
186
|
+
options["compact"] if options.key?("compact")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def output_schema_from_options
|
|
190
|
+
options = (@record["options"] || {}).transform_keys(&:to_s)
|
|
191
|
+
options["output_schema"] if options.key?("output_schema")
|
|
192
|
+
end
|
|
193
|
+
|
|
119
194
|
def persist_assistant_message(result)
|
|
120
195
|
if result.tool_calls?
|
|
121
|
-
conversation.append_message(
|
|
196
|
+
message = conversation.append_message(
|
|
122
197
|
role: "assistant",
|
|
123
198
|
kind: "tool_call",
|
|
124
199
|
text: result.text,
|
|
125
200
|
turn_id: id,
|
|
126
201
|
metadata: { "tool_calls" => result.tool_calls.map { |call| { "id" => call.id, "name" => call.name, "arguments" => call.arguments } } }
|
|
127
202
|
)
|
|
203
|
+
emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
|
|
204
|
+
result.tool_calls.each { |call| emit("tool_call.created", id: call.id, name: call.name) }
|
|
128
205
|
else
|
|
129
|
-
conversation.append_message(role: "assistant", kind: "text", text: result.text, turn_id: id)
|
|
206
|
+
message = conversation.append_message(role: "assistant", kind: "text", text: result.text, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
|
|
207
|
+
emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
|
|
130
208
|
end
|
|
131
209
|
end
|
|
132
210
|
|
|
133
211
|
def complete_from_terminal_tool(runner, execution)
|
|
134
212
|
message = runner.completion_message(execution)
|
|
135
|
-
conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
|
|
213
|
+
assistant = conversation.append_message(role: "assistant", kind: "text", text: message, turn_id: id)
|
|
214
|
+
emit("message.created", message_id: assistant.id, role: assistant.role, kind: assistant.kind)
|
|
136
215
|
update!(status: "completed", output_text: message, completed_at: Clock.now)
|
|
216
|
+
emit("turn.completed", status: status, output_text: message)
|
|
137
217
|
end
|
|
138
218
|
|
|
139
219
|
def add_usage!(usage, cost: nil)
|
|
@@ -164,6 +244,11 @@ module TurnKit
|
|
|
164
244
|
@model = @record["model"] || agent.effective_model
|
|
165
245
|
@record
|
|
166
246
|
end
|
|
247
|
+
|
|
248
|
+
def emit_event(event)
|
|
249
|
+
event = Event.new(type: event[:type] || event["type"], turn_id: id, conversation_id: conversation.id, payload: event[:payload] || event["payload"] || {}) if event.is_a?(Hash)
|
|
250
|
+
Array(@on_event || agent.effective_on_event).each { |callback| callback.call(event) }
|
|
251
|
+
end
|
|
167
252
|
end
|
|
168
253
|
|
|
169
254
|
class ToolContext
|