turnkit 0.5.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +198 -5
- data/UPGRADE.md +57 -0
- data/lib/generators/turnkit/install/templates/create_turnkit_tables.rb +30 -0
- data/lib/generators/turnkit/install/templates/delivery.rb +7 -0
- data/lib/generators/turnkit/install/templates/initializer.rb +5 -0
- data/lib/generators/turnkit/install/templates/wait.rb +7 -0
- data/lib/generators/turnkit/install_generator.rb +2 -0
- data/lib/generators/turnkit/upgrade/templates/add_turnkit_durable_orchestration.rb +36 -0
- data/lib/generators/turnkit/upgrade_generator.rb +34 -0
- data/lib/turnkit/active_record_store.rb +124 -14
- data/lib/turnkit/adapters/ruby_llm.rb +107 -21
- data/lib/turnkit/agent.rb +26 -20
- data/lib/turnkit/authorization.rb +17 -0
- data/lib/turnkit/background.rb +281 -0
- data/lib/turnkit/budget.rb +4 -3
- data/lib/turnkit/client.rb +3 -1
- data/lib/turnkit/conversation.rb +54 -1
- data/lib/turnkit/coordination_tools.rb +67 -0
- data/lib/turnkit/cost.rb +7 -7
- data/lib/turnkit/error.rb +3 -0
- data/lib/turnkit/execution_store.rb +30 -0
- data/lib/turnkit/id.rb +1 -0
- data/lib/turnkit/image_tool.rb +10 -0
- data/lib/turnkit/job.rb +21 -0
- data/lib/turnkit/memory_store.rb +113 -20
- data/lib/turnkit/message_projection.rb +4 -1
- data/lib/turnkit/reconciliation.rb +13 -10
- data/lib/turnkit/record.rb +36 -3
- data/lib/turnkit/run.rb +28 -0
- data/lib/turnkit/skill.rb +5 -4
- data/lib/turnkit/specialists.rb +254 -0
- data/lib/turnkit/store.rb +38 -9
- data/lib/turnkit/sub_agent_tool.rb +23 -7
- data/lib/turnkit/system_prompt.rb +7 -7
- data/lib/turnkit/tool.rb +11 -0
- data/lib/turnkit/tool_runner.rb +109 -45
- data/lib/turnkit/turn.rb +238 -61
- data/lib/turnkit/turn_controls.rb +136 -0
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +31 -0
- metadata +16 -5
|
@@ -9,11 +9,34 @@ module TurnKit
|
|
|
9
9
|
# persistence records. Turnkit::Conversation (a database row) is not
|
|
10
10
|
# TurnKit::Conversation (the domain object).
|
|
11
11
|
class ActiveRecordStore < Store
|
|
12
|
-
def initialize(conversation_class: "Turnkit::Conversation", turn_class: "Turnkit::Turn", message_class: "Turnkit::Message", tool_execution_class: "Turnkit::ToolExecution")
|
|
12
|
+
def initialize(conversation_class: "Turnkit::Conversation", turn_class: "Turnkit::Turn", message_class: "Turnkit::Message", tool_execution_class: "Turnkit::ToolExecution", delivery_class: "Turnkit::Delivery", wait_class: "Turnkit::Wait")
|
|
13
13
|
@conversation_class_name = conversation_class
|
|
14
14
|
@turn_class_name = turn_class
|
|
15
15
|
@message_class_name = message_class
|
|
16
16
|
@tool_execution_class_name = tool_execution_class
|
|
17
|
+
@delivery_class_name = delivery_class
|
|
18
|
+
@wait_class_name = wait_class
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def atomic(conversation_id)
|
|
22
|
+
conversation_class.connection_pool.with_connection do
|
|
23
|
+
conversation_class.transaction do
|
|
24
|
+
conversation_class.lock.find_by!(uid: conversation_id)
|
|
25
|
+
yield
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Wait-graph mutations use one database-wide transaction lock. PostgreSQL
|
|
31
|
+
# advisory xact locks serialize opposite edges even when roots differ.
|
|
32
|
+
def atomic_graph
|
|
33
|
+
conversation_class.connection_pool.with_connection do |connection|
|
|
34
|
+
conversation_class.transaction do
|
|
35
|
+
raise ConfigError, "atomic wait graphs require PostgreSQL" unless connection.adapter_name.match?(/postg/i)
|
|
36
|
+
connection.execute("SELECT pg_advisory_xact_lock(1414876747)")
|
|
37
|
+
yield
|
|
38
|
+
end
|
|
39
|
+
end
|
|
17
40
|
end
|
|
18
41
|
|
|
19
42
|
def create_conversation(attributes)
|
|
@@ -85,6 +108,8 @@ module TurnKit
|
|
|
85
108
|
cost: attrs["cost"],
|
|
86
109
|
error: attrs["error"],
|
|
87
110
|
output_text: attrs["output_text"],
|
|
111
|
+
submitted_at: attrs["submitted_at"],
|
|
112
|
+
claim_token: attrs["claim_token"],
|
|
88
113
|
started_at: attrs["started_at"],
|
|
89
114
|
heartbeat_at: attrs["heartbeat_at"],
|
|
90
115
|
completed_at: attrs["completed_at"]
|
|
@@ -123,6 +148,85 @@ module TurnKit
|
|
|
123
148
|
scope.order(:created_at, :uid).map { |record| turn_hash(record) }
|
|
124
149
|
end
|
|
125
150
|
|
|
151
|
+
def list_submitted_turns(limit: nil)
|
|
152
|
+
scope = turn_class.where.not(submitted_at: nil).order(:created_at, :uid)
|
|
153
|
+
scope = scope.limit(limit) if limit
|
|
154
|
+
scope.map { |record| turn_hash(record) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def list_actionable_turns(limit:)
|
|
158
|
+
turn_class.where.not(submitted_at: nil).where(status: %w[pending waiting running]).order(:updated_at, :uid).limit(limit).map { |record| turn_hash(record) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def list_stale_inline_turns(before:, limit:)
|
|
162
|
+
turn_class.where(submitted_at: nil, status: %w[pending running])
|
|
163
|
+
.where("COALESCE(heartbeat_at, started_at, created_at) < ?", before)
|
|
164
|
+
.order(:updated_at, :uid).limit(limit).map { |record| turn_hash(record) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def busy_conversation?(id, include_pending: true)
|
|
168
|
+
turns = turn_class.where(conversation_uid: id)
|
|
169
|
+
active = turns.where(status: %w[running waiting paused])
|
|
170
|
+
active = active.or(turns.where(status: "pending").where.not(submitted_at: nil)) if include_pending
|
|
171
|
+
active.exists?
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def next_delivery_trigger(id)
|
|
175
|
+
consumed = turn_class.where(conversation_uid: id).where.not(status: "pending", submitted_at: nil).maximum(:context_message_sequence).to_i
|
|
176
|
+
delivered_ids = delivery_class.where(destination_conversation_uid: id).where.not(message_uid: nil).select(:message_uid)
|
|
177
|
+
message = message_class.where(conversation_uid: id, uid: delivered_ids).where("sequence > ?", consumed).order(sequence: :desc).first
|
|
178
|
+
message_hash(message) if message
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def create_delivery(attributes)
|
|
182
|
+
attrs = Record.delivery(attributes)
|
|
183
|
+
record = delivery_class.create_or_find_by!(key: attrs.fetch("key")) do |delivery|
|
|
184
|
+
delivery.uid = attrs.fetch("id")
|
|
185
|
+
delivery.source_conversation_uid = attrs.fetch("source_conversation_id")
|
|
186
|
+
delivery.destination_conversation_uid = attrs.fetch("destination_conversation_id")
|
|
187
|
+
delivery.source_turn_uid = attrs["source_turn_id"]
|
|
188
|
+
delivery.payload = attrs.fetch("payload")
|
|
189
|
+
delivery.message_uid = attrs["message_id"]
|
|
190
|
+
delivery.delivered_at = attrs["delivered_at"]
|
|
191
|
+
delivery.created_at = attrs.fetch("created_at")
|
|
192
|
+
end
|
|
193
|
+
existing = delivery_hash(record)
|
|
194
|
+
Record.assert_delivery_retry!(existing, attrs)
|
|
195
|
+
existing
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def load_delivery(id)
|
|
199
|
+
delivery_hash(delivery_class.find_by!(uid: id))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def update_delivery(id, attributes)
|
|
203
|
+
record = delivery_class.find_by!(uid: id)
|
|
204
|
+
record.update!(Record.delivery_update(attributes).transform_keys { |key| key == "message_id" ? "message_uid" : key })
|
|
205
|
+
delivery_hash(record)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def list_deliveries(source_conversation_id: nil, destination_conversation_id: nil, pending: false, limit: nil)
|
|
209
|
+
scope = delivery_class.all
|
|
210
|
+
scope = scope.where(source_conversation_uid: source_conversation_id) if source_conversation_id
|
|
211
|
+
scope = scope.where(destination_conversation_uid: destination_conversation_id) if destination_conversation_id
|
|
212
|
+
scope = scope.where(delivered_at: nil) if pending
|
|
213
|
+
scope = scope.order(:created_at, :uid)
|
|
214
|
+
scope = scope.limit(limit) if limit
|
|
215
|
+
scope.map { |record| delivery_hash(record) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def create_wait(turn_id:, target_turn_id:)
|
|
219
|
+
record = wait_class.create_or_find_by!(turn_uid: turn_id, target_turn_uid: target_turn_id)
|
|
220
|
+
wait_hash(record)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def list_waits(turn_id: nil, target_turn_id: nil)
|
|
224
|
+
scope = wait_class.all
|
|
225
|
+
scope = scope.where(turn_uid: turn_id) if turn_id
|
|
226
|
+
scope = scope.where(target_turn_uid: target_turn_id) if target_turn_id
|
|
227
|
+
scope.order(:id).map { |record| wait_hash(record) }
|
|
228
|
+
end
|
|
229
|
+
|
|
126
230
|
def create_tool_execution(attributes)
|
|
127
231
|
attrs = Record.tool_execution(attributes)
|
|
128
232
|
record = tool_execution_class.create!(
|
|
@@ -156,23 +260,13 @@ module TurnKit
|
|
|
156
260
|
tool_execution_class.where(turn_uid: turn_id).order(:created_at, :uid).map { |record| tool_execution_hash(record) }
|
|
157
261
|
end
|
|
158
262
|
|
|
159
|
-
def reconcile_stale_turns(before:)
|
|
160
|
-
scope = turn_class.where(status: %w[pending running]).where("COALESCE(heartbeat_at, started_at, created_at) < ?", before)
|
|
161
|
-
scope.filter_map do |record|
|
|
162
|
-
now = Clock.now
|
|
163
|
-
affected = turn_class
|
|
164
|
-
.where(id: record.id, status: %w[pending running])
|
|
165
|
-
.where("COALESCE(heartbeat_at, started_at, created_at) < ?", before)
|
|
166
|
-
.update_all(status: "stale", completed_at: now, updated_at: now)
|
|
167
|
-
turn_hash(record.reload) if affected == 1
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
|
|
171
263
|
private
|
|
172
264
|
def conversation_class = constantize(@conversation_class_name)
|
|
173
265
|
def turn_class = constantize(@turn_class_name)
|
|
174
266
|
def message_class = constantize(@message_class_name)
|
|
175
267
|
def tool_execution_class = constantize(@tool_execution_class_name)
|
|
268
|
+
def delivery_class = constantize(@delivery_class_name)
|
|
269
|
+
def wait_class = constantize(@wait_class_name)
|
|
176
270
|
|
|
177
271
|
def constantize(name)
|
|
178
272
|
name.to_s.split("::").inject(Object) { |mod, part| mod.const_get(part) }
|
|
@@ -192,7 +286,9 @@ module TurnKit
|
|
|
192
286
|
end
|
|
193
287
|
|
|
194
288
|
def conversation_hash(record)
|
|
195
|
-
{ "id" => record.uid, "agent_name" => record.agent_name, "model" => record.model,
|
|
289
|
+
{ "id" => record.uid, "agent_name" => record.agent_name, "model" => record.model,
|
|
290
|
+
"subject" => record.subject_type && { "type" => record.subject_type, "id" => record.subject_id },
|
|
291
|
+
"metadata" => record.metadata || {}, "created_at" => record.created_at, "updated_at" => record.updated_at }
|
|
196
292
|
end
|
|
197
293
|
|
|
198
294
|
def turn_hash(record)
|
|
@@ -202,6 +298,7 @@ module TurnKit
|
|
|
202
298
|
"root_turn_id" => record.root_turn_uid, "context_message_sequence" => record.context_message_sequence,
|
|
203
299
|
"status" => record.status, "model" => record.model, "options" => record.options || {}, "usage" => record.usage || {},
|
|
204
300
|
"cost" => record.cost, "error" => record.error, "output_text" => record.output_text,
|
|
301
|
+
"submitted_at" => record.submitted_at, "claim_token" => record.claim_token,
|
|
205
302
|
"started_at" => record.started_at, "heartbeat_at" => record.heartbeat_at, "completed_at" => record.completed_at,
|
|
206
303
|
"created_at" => record.created_at, "updated_at" => record.updated_at
|
|
207
304
|
}
|
|
@@ -209,6 +306,19 @@ module TurnKit
|
|
|
209
306
|
attrs
|
|
210
307
|
end
|
|
211
308
|
|
|
309
|
+
def delivery_hash(record)
|
|
310
|
+
{
|
|
311
|
+
"id" => record.uid, "source_conversation_id" => record.source_conversation_uid,
|
|
312
|
+
"destination_conversation_id" => record.destination_conversation_uid, "source_turn_id" => record.source_turn_uid,
|
|
313
|
+
"key" => record.key, "payload" => record.payload || {}, "message_id" => record.message_uid,
|
|
314
|
+
"delivered_at" => record.delivered_at, "created_at" => record.created_at
|
|
315
|
+
}
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def wait_hash(record)
|
|
319
|
+
{ "turn_id" => record.turn_uid, "target_turn_id" => record.target_turn_uid }
|
|
320
|
+
end
|
|
321
|
+
|
|
212
322
|
def turn_has_attribute?(name)
|
|
213
323
|
turn_class.respond_to?(:attribute_names) && turn_class.attribute_names.include?(name)
|
|
214
324
|
end
|
|
@@ -10,9 +10,16 @@ module TurnKit
|
|
|
10
10
|
openrouter: "OPENROUTER_API_KEY"
|
|
11
11
|
}.freeze
|
|
12
12
|
|
|
13
|
+
def initialize(protocol: nil)
|
|
14
|
+
@protocol = protocol
|
|
15
|
+
end
|
|
16
|
+
|
|
13
17
|
def validate!(model:)
|
|
14
18
|
ensure_ruby_llm!
|
|
15
19
|
raise ModelAccessError, "model is required" if model.to_s.empty?
|
|
20
|
+
if @protocol && !::RubyLLM::Chat.method_defined?(:generate)
|
|
21
|
+
raise ConfigError, "protocol selection requires RubyLLM 2.0 (currently 2.0.0.rc2)"
|
|
22
|
+
end
|
|
16
23
|
|
|
17
24
|
configure_from_environment
|
|
18
25
|
provider = provider_for(model)
|
|
@@ -27,16 +34,27 @@ module TurnKit
|
|
|
27
34
|
ensure_ruby_llm!
|
|
28
35
|
configure_from_environment
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
validate!(model: model) if @protocol
|
|
38
|
+
chat = ::RubyLLM.chat(**{ model: model, protocol: @protocol }.compact)
|
|
39
|
+
chat.with_provider_options(metadata: metadata.transform_values(&:to_s)) if @protocol == :responses && metadata
|
|
31
40
|
add_instructions(chat, instructions, dynamic_instructions, model: model)
|
|
32
41
|
chat.with_temperature(temperature) if temperature
|
|
33
42
|
apply_thinking(chat, thinking)
|
|
34
43
|
chat.with_schema(normalize_schema(output_schema)) if output_schema
|
|
35
|
-
Array(tools).each
|
|
36
|
-
|
|
44
|
+
Array(tools).each do |tool|
|
|
45
|
+
chat.respond_to?(:with_tool) ? chat.with_tool(ruby_llm_tool(tool)) : chat.with_tools(ruby_llm_tool(tool))
|
|
46
|
+
end
|
|
47
|
+
tool_names = {}
|
|
48
|
+
Array(messages).each { |message| add_message(chat, message, provider: chat.model.provider, tool_names: tool_names) }
|
|
37
49
|
|
|
38
50
|
response = complete_without_tool_execution(chat)
|
|
39
51
|
normalize_response(response, model: model)
|
|
52
|
+
rescue ConfigError
|
|
53
|
+
raise
|
|
54
|
+
rescue ::RubyLLM::Error, ::RubyLLM::ModelNotFoundError => error
|
|
55
|
+
# Provider failures (after RubyLLM's own retries) are task failures,
|
|
56
|
+
# not worker crashes to be replayed by durable reconciliation.
|
|
57
|
+
raise ModelError, "#{error.class}: #{error.message}"
|
|
40
58
|
end
|
|
41
59
|
|
|
42
60
|
def paint(prompt:, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: nil, on_event: nil)
|
|
@@ -50,25 +68,44 @@ module TurnKit
|
|
|
50
68
|
size: size || "1024x1024",
|
|
51
69
|
with: input_images,
|
|
52
70
|
mask: mask,
|
|
53
|
-
|
|
71
|
+
**{ (::RubyLLM::Chat.method_defined?(:generate) ? :provider_options : :params) => params || {} }
|
|
54
72
|
)
|
|
55
73
|
normalize_image_response(image, model: model, provider: provider, params: { "size" => size || "1024x1024" }.merge(params || {}), metadata: metadata)
|
|
74
|
+
rescue ConfigError
|
|
75
|
+
raise
|
|
76
|
+
rescue ::RubyLLM::Error, ::RubyLLM::ModelNotFoundError => error
|
|
77
|
+
raise ModelError, "#{error.class}: #{error.message}"
|
|
56
78
|
end
|
|
57
79
|
|
|
58
80
|
def view_media(media:, objective:, model:, provider: nil, output_schema: nil, params: {}, metadata: nil, on_event: nil)
|
|
59
81
|
ensure_ruby_llm!
|
|
60
82
|
configure_from_environment
|
|
61
83
|
media_input = MediaInput.wrap(media)
|
|
62
|
-
content = ::RubyLLM::Content.new(objective.to_s)
|
|
63
|
-
content.add_attachment(media_input.attachment_source, filename: media_input.filename)
|
|
64
84
|
|
|
65
85
|
chat = ::RubyLLM.chat(model: model)
|
|
66
86
|
chat.with_schema(normalize_schema(output_schema)) if output_schema
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
if params && !params.empty?
|
|
88
|
+
if ::RubyLLM::Chat.method_defined?(:generate)
|
|
89
|
+
chat.with_provider_options(**params)
|
|
90
|
+
else
|
|
91
|
+
chat.with_params(**params)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
if ::RubyLLM::Chat.method_defined?(:generate)
|
|
95
|
+
attachment = ::RubyLLM::Attachment.new(media_input.attachment_source, filename: media_input.filename)
|
|
96
|
+
chat.add_message(role: :user, content: objective.to_s, attachments: [attachment])
|
|
97
|
+
else
|
|
98
|
+
content = ::RubyLLM::Content.new(objective.to_s)
|
|
99
|
+
content.add_attachment(media_input.attachment_source, filename: media_input.filename)
|
|
100
|
+
chat.add_message(role: :user, content: content)
|
|
101
|
+
end
|
|
69
102
|
|
|
70
103
|
response = complete_without_tool_execution(chat)
|
|
71
104
|
normalize_media_analysis_response(response, media: media_input, model: model, provider: provider, params: params || {}, metadata: metadata)
|
|
105
|
+
rescue ConfigError
|
|
106
|
+
raise
|
|
107
|
+
rescue ::RubyLLM::Error, ::RubyLLM::ModelNotFoundError => error
|
|
108
|
+
raise ModelError, "#{error.class}: #{error.message}"
|
|
72
109
|
end
|
|
73
110
|
|
|
74
111
|
private
|
|
@@ -119,12 +156,10 @@ module TurnKit
|
|
|
119
156
|
end
|
|
120
157
|
end
|
|
121
158
|
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
# (added in ruby_llm 1.16). TurnKit must run tools itself (to persist
|
|
125
|
-
# executions and enforce budgets). Guarded by a canary test in the
|
|
126
|
-
# suite; revisit when RubyLLM exposes a public equivalent.
|
|
159
|
+
# 2.0's public generate requests one completion without executing tools.
|
|
160
|
+
# 1.16 needs its private provider_completion, guarded by a canary test.
|
|
127
161
|
def complete_without_tool_execution(chat)
|
|
162
|
+
return chat.generate if chat.respond_to?(:generate)
|
|
128
163
|
unless chat.respond_to?(:provider_completion, true)
|
|
129
164
|
raise ConfigError, "TurnKit::Adapters::RubyLLM requires ruby_llm >= 1.16 (RubyLLM::Chat#provider_completion not found)"
|
|
130
165
|
end
|
|
@@ -132,15 +167,31 @@ module TurnKit
|
|
|
132
167
|
chat.send(:provider_completion)
|
|
133
168
|
end
|
|
134
169
|
|
|
135
|
-
def add_message(chat, message)
|
|
170
|
+
def add_message(chat, message, provider: nil, tool_names: {})
|
|
136
171
|
role = (message[:role] || message["role"]).to_sym
|
|
137
172
|
content = message[:content] || message["content"] || ""
|
|
173
|
+
kind = { "openai" => "openai_responses", "anthropic" => "anthropic", "gemini" => "gemini" }[provider.to_s]
|
|
174
|
+
replay = Array(message[:provider_parts] || message["provider_parts"]).find { |part| part["kind"] == kind } if kind
|
|
175
|
+
calls = ruby_llm_tool_calls(message[:tool_calls] || message["tool_calls"])
|
|
176
|
+
raw_content = replay&.fetch("data")
|
|
177
|
+
if raw_content && %w[anthropic gemini].include?(kind) && !::RubyLLM::Chat.method_defined?(:generate)
|
|
178
|
+
content = ::RubyLLM::Content::Raw.new(raw_content)
|
|
179
|
+
raw_content = nil
|
|
180
|
+
if kind == "gemini"
|
|
181
|
+
# 1.16 appends normalized calls after Raw content. Omit those
|
|
182
|
+
# duplicates and supply names for its positional function results.
|
|
183
|
+
calls&.each { |id, call| tool_names[id] = call.name }
|
|
184
|
+
calls = nil
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
call_id = message[:tool_call_id] || message["tool_call_id"]
|
|
138
188
|
chat.add_message(
|
|
139
189
|
{
|
|
140
190
|
role: role,
|
|
141
191
|
content: content,
|
|
142
|
-
|
|
143
|
-
|
|
192
|
+
raw_content: raw_content,
|
|
193
|
+
tool_calls: calls,
|
|
194
|
+
tool_call_id: tool_names.fetch(call_id, call_id)
|
|
144
195
|
}.compact
|
|
145
196
|
)
|
|
146
197
|
end
|
|
@@ -162,6 +213,10 @@ module TurnKit
|
|
|
162
213
|
content = content.to_s.strip
|
|
163
214
|
return if content.empty?
|
|
164
215
|
|
|
216
|
+
if ::RubyLLM::Chat.method_defined?(:generate)
|
|
217
|
+
chat.add_message(role: :system, content: content, cache_until_here: cache)
|
|
218
|
+
return
|
|
219
|
+
end
|
|
165
220
|
if cache
|
|
166
221
|
content = ::RubyLLM::Providers::Anthropic::Content.new(content, cache: true)
|
|
167
222
|
end
|
|
@@ -193,7 +248,7 @@ module TurnKit
|
|
|
193
248
|
Class.new(::RubyLLM::Tool) do
|
|
194
249
|
define_singleton_method(:name) { tool.tool_name }
|
|
195
250
|
description tool.description
|
|
196
|
-
params tool.input_schema
|
|
251
|
+
respond_to?(:params) ? params(tool.input_schema) : parameters(tool.input_schema)
|
|
197
252
|
|
|
198
253
|
define_method(:execute) do |**arguments|
|
|
199
254
|
raise ToolError, "tools must be executed by TurnKit turns, not the RubyLLM adapter"
|
|
@@ -202,6 +257,10 @@ module TurnKit
|
|
|
202
257
|
end
|
|
203
258
|
|
|
204
259
|
def normalize_response(response, model:)
|
|
260
|
+
raw = response.raw.body if response.respond_to?(:raw) && response.raw.respond_to?(:body)
|
|
261
|
+
if raw.is_a?(Hash) && raw["object"] == "response" && raw["status"] != "completed"
|
|
262
|
+
raise ModelError, "OpenAI Responses #{raw['status']}: #{raw.dig('incomplete_details', 'reason')}"
|
|
263
|
+
end
|
|
205
264
|
tool_calls = Array(response.respond_to?(:tool_calls) ? response.tool_calls&.values : []).map do |call|
|
|
206
265
|
ToolCall.new(id: call.id, name: call.name, arguments: call.arguments)
|
|
207
266
|
end
|
|
@@ -219,7 +278,7 @@ module TurnKit
|
|
|
219
278
|
output_data: response_data(response),
|
|
220
279
|
tool_calls: tool_calls,
|
|
221
280
|
usage: usage,
|
|
222
|
-
model:
|
|
281
|
+
model: response_model(response, model)
|
|
223
282
|
)
|
|
224
283
|
end
|
|
225
284
|
|
|
@@ -234,6 +293,14 @@ module TurnKit
|
|
|
234
293
|
text = content.to_s
|
|
235
294
|
text.empty? ? [] : [ { "type" => "text", "text" => text } ]
|
|
236
295
|
end.compact
|
|
296
|
+
raw = response.raw.body if response.respond_to?(:raw) && response.raw.respond_to?(:body)
|
|
297
|
+
if raw.is_a?(Hash) && raw["object"] == "response"
|
|
298
|
+
parts << { "type" => "provider", "kind" => "openai_responses", "data" => raw.fetch("output") }
|
|
299
|
+
elsif raw.is_a?(Hash) && raw["type"] == "message" && raw["role"] == "assistant"
|
|
300
|
+
parts << { "type" => "provider", "kind" => "anthropic", "data" => raw.fetch("content") }
|
|
301
|
+
elsif raw.is_a?(Hash) && raw.dig("candidates", 0, "content", "parts")
|
|
302
|
+
parts << { "type" => "provider", "kind" => "gemini", "data" => raw.dig("candidates", 0, "content", "parts") }
|
|
303
|
+
end
|
|
237
304
|
parts + Array(tool_calls).map { |call| { "type" => "tool_call", "id" => call.id, "name" => call.name, "arguments" => call.arguments } }
|
|
238
305
|
end
|
|
239
306
|
|
|
@@ -267,7 +334,25 @@ module TurnKit
|
|
|
267
334
|
end
|
|
268
335
|
|
|
269
336
|
def token_value(response, method)
|
|
270
|
-
response.respond_to?(method)
|
|
337
|
+
if response.respond_to?(method)
|
|
338
|
+
value = response.public_send(method).to_i
|
|
339
|
+
raw = response.raw.body if response.respond_to?(:raw) && response.raw.respond_to?(:body)
|
|
340
|
+
native = raw.is_a?(Hash) && (raw["candidates"] || raw["type"] == "message")
|
|
341
|
+
# Native 1.16 providers also include thinking in their output count.
|
|
342
|
+
return native && method == :output_tokens ? value - thinking_token_value(response) : value
|
|
343
|
+
end
|
|
344
|
+
return 0 unless response.respond_to?(:tokens)
|
|
345
|
+
|
|
346
|
+
key = { input_tokens: :input, output_tokens: :output, cached_tokens: :cache_read,
|
|
347
|
+
cache_creation_tokens: :cache_write, thinking_tokens: :thinking, reasoning_tokens: :thinking }.fetch(method)
|
|
348
|
+
value = response.tokens.public_send(key).to_i
|
|
349
|
+
# RubyLLM 2.0 includes thinking in output; TurnKit buckets are additive.
|
|
350
|
+
method == :output_tokens ? value - response.tokens.thinking.to_i : value
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def response_model(response, fallback)
|
|
354
|
+
return response.model if response.respond_to?(:model)
|
|
355
|
+
response.respond_to?(:model_id) ? response.model_id : fallback
|
|
271
356
|
end
|
|
272
357
|
|
|
273
358
|
def thinking_token_value(response)
|
|
@@ -291,7 +376,7 @@ module TurnKit
|
|
|
291
376
|
data: image.respond_to?(:data) ? image.data : nil,
|
|
292
377
|
mime_type: image.respond_to?(:mime_type) ? image.mime_type : nil,
|
|
293
378
|
revised_prompt: image.respond_to?(:revised_prompt) ? image.revised_prompt : nil,
|
|
294
|
-
model:
|
|
379
|
+
model: response_model(image, model),
|
|
295
380
|
provider: provider&.to_s,
|
|
296
381
|
usage: usage,
|
|
297
382
|
params: params,
|
|
@@ -313,7 +398,7 @@ module TurnKit
|
|
|
313
398
|
part = MediaAnalysisResult.new(
|
|
314
399
|
text: response_text(response),
|
|
315
400
|
data: response_data(response),
|
|
316
|
-
model:
|
|
401
|
+
model: response_model(response, model),
|
|
317
402
|
provider: provider&.to_s,
|
|
318
403
|
usage: usage,
|
|
319
404
|
params: params,
|
|
@@ -325,6 +410,7 @@ module TurnKit
|
|
|
325
410
|
end
|
|
326
411
|
|
|
327
412
|
def image_usage_value(image, key)
|
|
413
|
+
return image.tokens.public_send(key == "input_tokens" ? :input : :output).to_i if image.respond_to?(:tokens)
|
|
328
414
|
usage = image.respond_to?(:usage) ? image.usage || {} : {}
|
|
329
415
|
(usage[key] || usage[key.to_sym]).to_i
|
|
330
416
|
end
|
data/lib/turnkit/agent.rb
CHANGED
|
@@ -24,21 +24,21 @@ module TurnKit
|
|
|
24
24
|
attr_reader :name, :description, :model, :instructions, :tools, :skills, :available_skills, :sub_agents
|
|
25
25
|
attr_reader :client, :store, :max_iterations, :timeout, :max_spend, :max_depth, :max_tool_executions, :max_tool_executions_by_name
|
|
26
26
|
attr_reader :prompt_sections, :system_prompt, :prompt_mode, :thinking, :compaction, :output_schema, :input_schema, :on_event
|
|
27
|
-
attr_reader :output_policy, :output_policy_mode, :output_policy_model, :output_retries
|
|
27
|
+
attr_reader :output_policy, :output_policy_mode, :output_policy_model, :output_retries, :context_contributors
|
|
28
28
|
|
|
29
29
|
def initialize(name:, description: "", model: nil, instructions: "", orchestrator: false, tools: [], skills: [], available_skills: [], sub_agents: [],
|
|
30
30
|
system_prompt: nil, prompt_sections: nil, prompt_mode: nil, client: nil, store: nil,
|
|
31
31
|
max_iterations: nil, timeout: nil, max_spend: nil, max_depth: nil, max_tool_executions: nil, max_tool_executions_by_name: nil, thinking: nil, compaction: nil,
|
|
32
|
-
output_schema: nil, input_schema: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, output_retries: 0, on_event: nil)
|
|
32
|
+
output_schema: nil, input_schema: nil, output_policy: nil, output_policy_mode: nil, output_policy_model: nil, output_policy_thinking: nil, output_retries: 0, on_event: nil, context_contributors: [], inherit_globals: true)
|
|
33
33
|
@name = name.to_s
|
|
34
34
|
@description = description.to_s
|
|
35
35
|
@model = model
|
|
36
36
|
@orchestrator = orchestrator ? true : false
|
|
37
37
|
@instructions = compose_instructions(instructions)
|
|
38
|
-
@tools = Array(tools)
|
|
39
|
-
@skills = Array(skills)
|
|
40
|
-
@available_skills = Array(available_skills)
|
|
41
|
-
@sub_agents = Array(sub_agents)
|
|
38
|
+
@tools = Array(tools).dup.freeze
|
|
39
|
+
@skills = Array(skills).dup.freeze
|
|
40
|
+
@available_skills = ((inherit_globals ? Array(TurnKit.available_skills) : []) + Array(available_skills)).uniq { |skill| skill.key }.freeze
|
|
41
|
+
@sub_agents = Array(sub_agents).dup.freeze
|
|
42
42
|
@system_prompt = system_prompt
|
|
43
43
|
@prompt_sections = prompt_sections
|
|
44
44
|
@prompt_mode = prompt_mode&.to_sym || (:task if @orchestrator)
|
|
@@ -59,6 +59,7 @@ module TurnKit
|
|
|
59
59
|
@output_policy_mode = normalize_output_policy_mode(output_policy_mode)
|
|
60
60
|
@output_retries = Integer(output_retries || 0)
|
|
61
61
|
@on_event = on_event
|
|
62
|
+
@context_contributors = ((inherit_globals ? Array(TurnKit.context_contributors) : []) + Array(context_contributors)).freeze
|
|
62
63
|
raise ArgumentError, "name is required" if @name.empty?
|
|
63
64
|
validate_tools!
|
|
64
65
|
end
|
|
@@ -78,32 +79,35 @@ module TurnKit
|
|
|
78
79
|
attrs.slice(:effort, :budget).compact
|
|
79
80
|
end
|
|
80
81
|
|
|
81
|
-
def conversation(model: nil, subject: nil, metadata: {})
|
|
82
|
+
def conversation(model: nil, subject: nil, metadata: {}, context: {}, principal: nil)
|
|
82
83
|
store = effective_store
|
|
84
|
+
context = JSON.parse(JSON.generate(context))
|
|
85
|
+
metadata = metadata.merge("principal" => principal) unless principal.nil?
|
|
86
|
+
metadata = metadata.merge("turnkit_subject_prompt" => subject.to_prompt.to_s) if subject.respond_to?(:to_prompt)
|
|
83
87
|
record = store.create_conversation(
|
|
84
88
|
"agent_name" => name,
|
|
85
89
|
"model" => model || effective_model,
|
|
86
90
|
"subject" => subject,
|
|
87
|
-
"metadata" => metadata
|
|
91
|
+
"metadata" => metadata.merge("turnkit_context" => context)
|
|
88
92
|
)
|
|
89
|
-
Conversation.new(agent: self, record: record, store: store, model: model || effective_model, subject: subject, metadata: metadata)
|
|
93
|
+
Conversation.new(agent: self, record: record, store: store, model: model || effective_model, subject: subject, metadata: metadata.merge("turnkit_context" => context))
|
|
90
94
|
end
|
|
91
95
|
|
|
92
96
|
def orchestrator?
|
|
93
97
|
@orchestrator
|
|
94
98
|
end
|
|
95
99
|
|
|
96
|
-
def run(task, input: nil, async: false, subject: nil, metadata: {}, parent_run: nil, root_turn_id: nil, prompt_mode: :task, **options)
|
|
100
|
+
def run(task, input: nil, async: false, subject: nil, metadata: {}, context: {}, principal: nil, parent_run: nil, root_turn_id: nil, prompt_mode: :task, **options)
|
|
97
101
|
raise ArgumentError, "task is required" if task.to_s.empty?
|
|
98
102
|
SchemaCheck.validate!(input, input_schema, error_class: InputError, label: "input") if input_schema
|
|
99
103
|
|
|
100
|
-
conversation = self.conversation(subject: subject, metadata: metadata)
|
|
104
|
+
conversation = self.conversation(subject: subject, metadata: metadata, context: context, principal: principal)
|
|
101
105
|
message = conversation.say(task_message(task, input), metadata: { "source" => "application", "task" => true })
|
|
102
106
|
turn = conversation.build_turn(
|
|
103
107
|
trigger_message_id: message.id,
|
|
104
108
|
root_turn_id: root_turn_id || parent_run_root_turn_id(parent_run),
|
|
105
109
|
prompt_mode: prompt_mode,
|
|
106
|
-
**options
|
|
110
|
+
principal: principal, context: context, **options
|
|
107
111
|
)
|
|
108
112
|
run = Run.new(turn)
|
|
109
113
|
async ? run : run.run!
|
|
@@ -137,10 +141,11 @@ module TurnKit
|
|
|
137
141
|
store || TurnKit.store
|
|
138
142
|
end
|
|
139
143
|
|
|
140
|
-
def effective_tools
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
+
def effective_tools(turn: nil)
|
|
145
|
+
loaded = turn ? turn.tool_executions.select { |execution| execution.tool_name == "load_skill" && execution.completed? }.map { |execution| execution.result.fetch("key") } : []
|
|
146
|
+
active_skills = skills + available_skills.select { |skill| loaded.include?(skill.key) }
|
|
147
|
+
configured = tools + active_skills.flat_map(&:tools) + sub_agents.map { |agent| SubAgentTool.for(agent) }
|
|
148
|
+
available_skills.empty? ? configured : configured + [ LoadSkillTool.for(available_skills) ]
|
|
144
149
|
end
|
|
145
150
|
|
|
146
151
|
def effective_on_event
|
|
@@ -148,7 +153,7 @@ module TurnKit
|
|
|
148
153
|
end
|
|
149
154
|
|
|
150
155
|
def effective_available_skills
|
|
151
|
-
|
|
156
|
+
available_skills
|
|
152
157
|
end
|
|
153
158
|
|
|
154
159
|
def effective_prompt_sections
|
|
@@ -204,18 +209,19 @@ module TurnKit
|
|
|
204
209
|
end
|
|
205
210
|
|
|
206
211
|
def validate_tools!
|
|
207
|
-
effective_tools
|
|
212
|
+
all_tools = effective_tools + available_skills.flat_map(&:tools)
|
|
213
|
+
all_tools.each do |tool|
|
|
208
214
|
next if tool.is_a?(Class) && tool < Tool
|
|
209
215
|
next if tool.is_a?(Tool)
|
|
210
216
|
|
|
211
217
|
raise ArgumentError, "tools must be TurnKit::Tool classes or instances"
|
|
212
218
|
end
|
|
213
219
|
|
|
214
|
-
names =
|
|
220
|
+
names = all_tools.map(&:tool_name)
|
|
215
221
|
duplicate = names.find { |name| names.count(name) > 1 }
|
|
216
222
|
raise ArgumentError, "duplicate tool name: #{duplicate}" if duplicate
|
|
217
223
|
|
|
218
|
-
|
|
224
|
+
all_tools.each(&:validate_definition!)
|
|
219
225
|
end
|
|
220
226
|
|
|
221
227
|
def normalize_output_policy(value, model: nil, thinking: nil)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
# Application-owned authorization boundary. A configured policy must return
|
|
5
|
+
# true explicitly; the model never supplies the principal.
|
|
6
|
+
module Authorization
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def authorize!(action, principal:, **resource)
|
|
10
|
+
policy = TurnKit.authorization_policy
|
|
11
|
+
return true unless policy
|
|
12
|
+
allowed = policy.respond_to?(:authorize?) ? policy.authorize?(action, principal: principal, **resource) : policy.call(action, principal: principal, **resource)
|
|
13
|
+
raise AuthorizationError, "not authorized to #{action}" unless allowed == true
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|