turnkit 0.5.0 → 0.6.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 +23 -0
- data/README.md +187 -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 +14 -0
- 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/conversation.rb +23 -1
- data/lib/turnkit/coordination_tools.rb +61 -0
- 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 +106 -15
- data/lib/turnkit/reconciliation.rb +13 -10
- data/lib/turnkit/record.rb +36 -3
- data/lib/turnkit/run.rb +15 -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 +101 -45
- data/lib/turnkit/turn.rb +188 -57
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +30 -0
- metadata +15 -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])
|
|
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
|
|
@@ -37,6 +37,12 @@ module TurnKit
|
|
|
37
37
|
|
|
38
38
|
response = complete_without_tool_execution(chat)
|
|
39
39
|
normalize_response(response, model: model)
|
|
40
|
+
rescue ConfigError
|
|
41
|
+
raise
|
|
42
|
+
rescue ::RubyLLM::Error, ::RubyLLM::ModelNotFoundError => error
|
|
43
|
+
# Provider failures (after RubyLLM's own retries) are task failures,
|
|
44
|
+
# not worker crashes to be replayed by durable reconciliation.
|
|
45
|
+
raise ModelError, "#{error.class}: #{error.message}"
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
def paint(prompt:, model:, provider: nil, size: nil, assume_model_exists: nil, input_images: nil, mask: nil, params: {}, metadata: nil, on_event: nil)
|
|
@@ -53,6 +59,10 @@ module TurnKit
|
|
|
53
59
|
params: params || {}
|
|
54
60
|
)
|
|
55
61
|
normalize_image_response(image, model: model, provider: provider, params: { "size" => size || "1024x1024" }.merge(params || {}), metadata: metadata)
|
|
62
|
+
rescue ConfigError
|
|
63
|
+
raise
|
|
64
|
+
rescue ::RubyLLM::Error, ::RubyLLM::ModelNotFoundError => error
|
|
65
|
+
raise ModelError, "#{error.class}: #{error.message}"
|
|
56
66
|
end
|
|
57
67
|
|
|
58
68
|
def view_media(media:, objective:, model:, provider: nil, output_schema: nil, params: {}, metadata: nil, on_event: nil)
|
|
@@ -69,6 +79,10 @@ module TurnKit
|
|
|
69
79
|
|
|
70
80
|
response = complete_without_tool_execution(chat)
|
|
71
81
|
normalize_media_analysis_response(response, media: media_input, model: model, provider: provider, params: params || {}, metadata: metadata)
|
|
82
|
+
rescue ConfigError
|
|
83
|
+
raise
|
|
84
|
+
rescue ::RubyLLM::Error, ::RubyLLM::ModelNotFoundError => error
|
|
85
|
+
raise ModelError, "#{error.class}: #{error.message}"
|
|
72
86
|
end
|
|
73
87
|
|
|
74
88
|
private
|
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
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
# Jobs are wake-up signals. Submitted turns, deliveries, and waits in the
|
|
5
|
+
# store are authoritative and can be rediscovered after a lost enqueue.
|
|
6
|
+
module Background
|
|
7
|
+
TERMINAL = %w[completed failed cancelled].freeze
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def root_conversation(store, record)
|
|
11
|
+
store.load_turn(record.fetch("root_turn_id")).fetch("conversation_id")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def load_turn(id, store: TurnKit.store)
|
|
15
|
+
record = store.load_turn(id)
|
|
16
|
+
agent = TurnKit.resolve_agent(record.fetch("agent_name"))
|
|
17
|
+
conversation = load_conversation(record.fetch("conversation_id"), store: store, agent: agent)
|
|
18
|
+
depth = 0
|
|
19
|
+
ancestor = record
|
|
20
|
+
while ancestor["parent_turn_id"]
|
|
21
|
+
depth += 1
|
|
22
|
+
ancestor = store.load_turn(ancestor.fetch("parent_turn_id"))
|
|
23
|
+
end
|
|
24
|
+
Turn.new(agent: agent, conversation: conversation, record: record, store: store, depth: depth)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def load_conversation(id, store: TurnKit.store, agent: nil)
|
|
28
|
+
record = store.load_conversation(id)
|
|
29
|
+
agent ||= TurnKit.resolve_agent(record.fetch("agent_name"))
|
|
30
|
+
Conversation.new(agent: agent, record: record, store: store, model: record["model"], subject: record["subject"], metadata: record["metadata"])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def submit(turn, callback: nil)
|
|
34
|
+
store = turn.store
|
|
35
|
+
raw = store
|
|
36
|
+
raw = raw.__getobj__ while raw.is_a?(ExecutionStore)
|
|
37
|
+
raise ConfigError, "background turns must use TurnKit.store" unless raw.equal?(TurnKit.store)
|
|
38
|
+
TurnKit.resolve_agent(turn.agent_name)
|
|
39
|
+
principal = store.load_turn(turn.id).dig("options", "principal")
|
|
40
|
+
Authorization.authorize!(:callback, principal: principal, turn: turn.id, destination_conversation: callback) if callback
|
|
41
|
+
store.load_conversation(callback) if callback
|
|
42
|
+
store.atomic(root_conversation(store, store.load_turn(turn.id))) do
|
|
43
|
+
record = store.load_turn(turn.id)
|
|
44
|
+
raise Error, "only pending turns can be submitted" unless record["status"] == "pending"
|
|
45
|
+
options = record.fetch("options")
|
|
46
|
+
options = options.merge("callback_conversation_id" => callback) if callback
|
|
47
|
+
store.update_turn(turn.id, submitted_at: record["submitted_at"] || Clock.now, options: options,
|
|
48
|
+
status: ready?(store, turn.id) ? "pending" : "waiting")
|
|
49
|
+
end
|
|
50
|
+
enqueue(turn.id)
|
|
51
|
+
turn.reload
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def enqueue(id = nil)
|
|
55
|
+
if TurnKit.store.is_a?(ActiveRecordStore)
|
|
56
|
+
require_relative "job"
|
|
57
|
+
ActiveRecord.after_all_transactions_commit { dispatch_job(id) }
|
|
58
|
+
else
|
|
59
|
+
dispatch_job(id)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def dispatch_job(id)
|
|
64
|
+
if TurnKit.job_dispatcher
|
|
65
|
+
TurnKit.job_dispatcher.call(id)
|
|
66
|
+
else
|
|
67
|
+
raise ConfigError, "background jobs require ActiveRecordStore" unless TurnKit.store.is_a?(ActiveRecordStore)
|
|
68
|
+
require_relative "job"
|
|
69
|
+
if [ActiveJob::QueueAdapters::InlineAdapter, ActiveJob::QueueAdapters::AsyncAdapter].any? { |type| Job.queue_adapter.is_a?(type) }
|
|
70
|
+
raise ConfigError, "configure a persistent Active Job backend (not inline or async)"
|
|
71
|
+
end
|
|
72
|
+
Job.perform_later(id)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def perform(id = nil)
|
|
77
|
+
if id
|
|
78
|
+
turn = load_turn(id)
|
|
79
|
+
turn.run! if turn.background?
|
|
80
|
+
end
|
|
81
|
+
drain
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def claim(store, record)
|
|
85
|
+
store.atomic(root_conversation(store, record)) do
|
|
86
|
+
store.atomic(record.fetch("conversation_id")) do
|
|
87
|
+
current = store.load_turn(record.fetch("id"))
|
|
88
|
+
next unless current["status"] == "pending"
|
|
89
|
+
state = current.dig("options", "state") || {}
|
|
90
|
+
next if !state["phase"] && !ready?(store, current.fetch("id")) && !deadline_exceeded?(store, current)
|
|
91
|
+
if current["submitted_at"]
|
|
92
|
+
others = store.list_turns(conversation_id: current.fetch("conversation_id")).reject { |row| row["id"] == current["id"] }
|
|
93
|
+
next if others.any? { |row| %w[running waiting].include?(row["status"]) }
|
|
94
|
+
first = ([current] + others.select { |row| row["submitted_at"] && row["status"] == "pending" }).min_by { |row| [row["created_at"], row["id"]] }
|
|
95
|
+
next unless first["id"] == current["id"]
|
|
96
|
+
end
|
|
97
|
+
# An application-level join supplies its results as turn-local input.
|
|
98
|
+
# Tool-level joins already have a persisted tools phase/result channel.
|
|
99
|
+
waits = store.list_waits(turn_id: current.fetch("id"))
|
|
100
|
+
if waits.any? && !state["phase"] && !state["wait_input"] && ready?(store, current.fetch("id"))
|
|
101
|
+
results = waits.map { |wait| SubAgentTool.result(store.load_turn(wait.fetch("target_turn_id"))) }
|
|
102
|
+
store.append_message("conversation_id" => current.fetch("conversation_id"), "turn_id" => current.fetch("id"),
|
|
103
|
+
"role" => "user", "kind" => "text", "text" => { "wait_results" => results }.to_json)
|
|
104
|
+
current = store.update_turn(current.fetch("id"), options: current.fetch("options").merge("state" => state.merge("wait_input" => true)))
|
|
105
|
+
end
|
|
106
|
+
store.claim_turn(current.fetch("id"), started_at: current["started_at"] || Clock.now, heartbeat_at: Clock.now, claim_token: SecureRandom.hex(16))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def send_message(source:, destination:, text:, key:, store: TurnKit.store, source_turn_id: nil, principal: nil)
|
|
112
|
+
Authorization.authorize!(:send_message, principal: principal, source_conversation: source, destination_conversation: destination)
|
|
113
|
+
store.load_conversation(destination)
|
|
114
|
+
delivery = store.create_delivery(
|
|
115
|
+
"source_conversation_id" => source, "destination_conversation_id" => destination,
|
|
116
|
+
"source_turn_id" => source_turn_id, "key" => key, "payload" => { "text" => text }
|
|
117
|
+
)
|
|
118
|
+
enqueue
|
|
119
|
+
delivery
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def callback(store, record)
|
|
123
|
+
destination = record.dig("options", "callback_conversation_id")
|
|
124
|
+
return unless destination && TERMINAL.include?(record["status"])
|
|
125
|
+
|
|
126
|
+
Authorization.authorize!(:callback, principal: record.dig("options", "principal"), turn: record.fetch("id"), destination_conversation: destination)
|
|
127
|
+
|
|
128
|
+
store.create_delivery(
|
|
129
|
+
"source_conversation_id" => record.fetch("conversation_id"), "destination_conversation_id" => destination,
|
|
130
|
+
"source_turn_id" => record.fetch("id"), "key" => "completion:#{record.fetch('id')}",
|
|
131
|
+
"payload" => { "text" => SubAgentTool.result(record).to_json }
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def callback_after_terminal(store, record)
|
|
136
|
+
callback(store, record)
|
|
137
|
+
rescue AuthorizationError => error
|
|
138
|
+
options = record.fetch("options").merge("callback_denied" => {
|
|
139
|
+
"class" => error.class.name, "message" => error.message, "at" => Clock.now.iso8601
|
|
140
|
+
})
|
|
141
|
+
store.update_turn(record.fetch("id"), options: options)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def deliver(delivery, store: TurnKit.store)
|
|
145
|
+
destination = delivery.fetch("destination_conversation_id")
|
|
146
|
+
store.atomic(destination) do
|
|
147
|
+
delivery = store.load_delivery(delivery.fetch("id"))
|
|
148
|
+
next if delivery["delivered_at"]
|
|
149
|
+
|
|
150
|
+
message = store.append_message(
|
|
151
|
+
"conversation_id" => destination, "role" => "user", "kind" => "text",
|
|
152
|
+
"text" => delivery.fetch("payload").fetch("text"),
|
|
153
|
+
"metadata" => { "delivery_id" => delivery.fetch("id"), "source_conversation_id" => delivery["source_conversation_id"], "source_turn_id" => delivery["source_turn_id"] }
|
|
154
|
+
)
|
|
155
|
+
store.update_delivery(delivery.fetch("id"), message_id: message.fetch("id"), delivered_at: Clock.now)
|
|
156
|
+
wake(destination, store: store)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def wake(conversation_id, store: TurnKit.store)
|
|
161
|
+
store.atomic(conversation_id) do
|
|
162
|
+
next if store.busy_conversation?(conversation_id)
|
|
163
|
+
incoming = store.next_delivery_trigger(conversation_id)
|
|
164
|
+
next unless incoming
|
|
165
|
+
|
|
166
|
+
conversation = load_conversation(conversation_id, store: store)
|
|
167
|
+
turn = conversation.build_turn(trigger_message_id: incoming.fetch("id"), principal: conversation.metadata["principal"])
|
|
168
|
+
store.update_turn(turn.id, submitted_at: Clock.now)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def wait(turn, targets, transition: false)
|
|
173
|
+
ids = Array(targets).map { |target| target.respond_to?(:id) ? target.id : target.to_s }.uniq
|
|
174
|
+
principal = turn.store.load_turn(turn.id).dig("options", "principal")
|
|
175
|
+
Authorization.authorize!(:wait, principal: principal, turn: turn.id, targets: ids)
|
|
176
|
+
turn.store.atomic_graph do
|
|
177
|
+
turn.store.atomic(root_conversation(turn.store, turn.store.load_turn(turn.id))) do
|
|
178
|
+
current = turn.store.load_turn(turn.id)
|
|
179
|
+
raise Error, "only pending turns can wait" if transition && current["status"] != "pending"
|
|
180
|
+
ids.each do |id|
|
|
181
|
+
raise ToolError, "a turn cannot wait for itself" if id == turn.id
|
|
182
|
+
target = turn.store.load_turn(id)
|
|
183
|
+
raise ToolError, "wait targets must be submitted" unless target["submitted_at"] || TERMINAL.include?(target["status"])
|
|
184
|
+
if target["conversation_id"] == turn.conversation.id && !TERMINAL.include?(target["status"])
|
|
185
|
+
raise ToolError, "cannot wait for unfinished work in the same conversation"
|
|
186
|
+
end
|
|
187
|
+
raise ToolError, "wait would create a cycle" if wait_reachable?(turn.store, id, turn.id)
|
|
188
|
+
turn.store.create_wait(turn_id: turn.id, target_turn_id: id)
|
|
189
|
+
end
|
|
190
|
+
turn.store.update_turn(turn.id, status: "waiting") if transition && current["submitted_at"] && !ready?(turn.store, turn.id)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
ids
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def wait_reachable?(store, from, sought, seen = {})
|
|
197
|
+
target = store.load_turn(from)
|
|
198
|
+
return false if TERMINAL.include?(target["status"])
|
|
199
|
+
conversation = target.fetch("conversation_id")
|
|
200
|
+
return true if conversation == store.load_turn(sought).fetch("conversation_id")
|
|
201
|
+
return false if seen[conversation]
|
|
202
|
+
seen[conversation] = true
|
|
203
|
+
# A conversation is a serial execution lane. Conservatively include waits
|
|
204
|
+
# of every unfinished peer, not just explicit edges on the requested turn.
|
|
205
|
+
store.list_turns(conversation_id: conversation).reject { |row| TERMINAL.include?(row["status"]) }.any? do |row|
|
|
206
|
+
store.list_waits(turn_id: row.fetch("id")).any? { |edge| wait_reachable?(store, edge.fetch("target_turn_id"), sought, seen) }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def ready?(store, id)
|
|
211
|
+
store.list_waits(turn_id: id).all? { |wait| TERMINAL.include?(store.load_turn(wait.fetch("target_turn_id"))["status"]) }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def deadline_exceeded?(store, record)
|
|
215
|
+
root = store.load_turn(record.fetch("root_turn_id"))
|
|
216
|
+
limits = root.dig("options", "budget_limits") || {}
|
|
217
|
+
timeout = limits.fetch("timeout", TurnKit.timeout)
|
|
218
|
+
start = root["submitted_at"] || root["started_at"]
|
|
219
|
+
timeout && start && Clock.now >= start + timeout
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def drain(store: TurnKit.store)
|
|
223
|
+
limit = TurnKit.maintenance_batch_size
|
|
224
|
+
store.list_deliveries(pending: true, limit: limit).each { |delivery| deliver(delivery, store: store) }
|
|
225
|
+
turns = store.list_actionable_turns(limit: limit)
|
|
226
|
+
turns.each do |record|
|
|
227
|
+
if record["status"] == "waiting"
|
|
228
|
+
store.atomic(root_conversation(store, record)) do
|
|
229
|
+
current = store.load_turn(record.fetch("id"))
|
|
230
|
+
next unless current["status"] == "waiting"
|
|
231
|
+
if ready?(store, current.fetch("id")) || deadline_exceeded?(store, current)
|
|
232
|
+
store.claim_turn(current.fetch("id"), from: "waiting", to: "pending")
|
|
233
|
+
else
|
|
234
|
+
store.claim_turn(current.fetch("id"), from: "waiting", to: "waiting") # rotate safely
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
elsif record["status"] == "running"
|
|
238
|
+
store.claim_turn(record.fetch("id"), from: "running", to: "running") # rotate without changing heartbeat
|
|
239
|
+
elsif record["status"] == "pending"
|
|
240
|
+
store.claim_turn(record.fetch("id"), from: "pending", to: "pending") # blocked conversations must rotate too
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
turns.group_by { |record| record.fetch("conversation_id") }.each do |conversation_id, records|
|
|
244
|
+
next if store.busy_conversation?(conversation_id, include_pending: false)
|
|
245
|
+
record = records.find { |row| row["status"] == "pending" }
|
|
246
|
+
if record
|
|
247
|
+
rotated = store.claim_turn(record.fetch("id"), from: "pending", to: "pending")
|
|
248
|
+
enqueue(record.fetch("id")) if rotated
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def reconcile(before: Clock.now - (TurnKit.timeout || 300), store: TurnKit.store)
|
|
254
|
+
store.list_actionable_turns(limit: TurnKit.maintenance_batch_size).each do |record|
|
|
255
|
+
store.atomic(root_conversation(store, record)) do
|
|
256
|
+
record = store.load_turn(record.fetch("id"))
|
|
257
|
+
next unless record["status"] == "running" && record.fetch("heartbeat_at") < before
|
|
258
|
+
|
|
259
|
+
store.update_turn(record.fetch("id"), claim_token: nil)
|
|
260
|
+
# A pending child is known work, not an interrupted external effect.
|
|
261
|
+
executions = store.list_tool_executions(turn_id: record.fetch("id"))
|
|
262
|
+
executions.each do |execution|
|
|
263
|
+
next unless execution["status"] == "running"
|
|
264
|
+
loaded = Background.load_turn(record.fetch("id"), store: store)
|
|
265
|
+
tool = loaded.agent.effective_tools(turn: loaded).find { |candidate| candidate.tool_name == execution["tool_name"] }
|
|
266
|
+
recovery = tool.is_a?(Class) ? tool.recovery : tool&.class&.recovery
|
|
267
|
+
ordinary = tool && !(tool.is_a?(Class) && tool < SubAgentTool) &&
|
|
268
|
+
![WaitTool, LaunchAgentTool, SendMessageTool].include?(tool)
|
|
269
|
+
if ordinary && recovery == :replay_safe
|
|
270
|
+
store.claim_tool_execution(execution.fetch("id"), to: "pending", started_at: nil)
|
|
271
|
+
next
|
|
272
|
+
end
|
|
273
|
+
store.claim_tool_execution(execution.fetch("id"), to: "interrupted", error: { "message" => Reconciliation::INTERRUPTED_MESSAGE }, completed_at: Clock.now)
|
|
274
|
+
end
|
|
275
|
+
store.update_turn(record.fetch("id"), status: "pending", heartbeat_at: nil)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
drain(store: store)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|