turnkit 0.4.2 → 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 +39 -0
- data/README.md +197 -1
- 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 +130 -10
- 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 +110 -14
- data/lib/turnkit/reconciliation.rb +75 -0
- data/lib/turnkit/record.rb +37 -4
- 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 +42 -2
- 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 -29
- data/lib/turnkit/turn.rb +188 -57
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +32 -3
- 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])
|
|
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!(
|
|
@@ -144,25 +248,25 @@ module TurnKit
|
|
|
144
248
|
tool_execution_hash(tool_execution_class.find_by!(uid: id))
|
|
145
249
|
end
|
|
146
250
|
|
|
147
|
-
def
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
251
|
+
def claim_tool_execution(id, from: "running", to: "completed", **attributes)
|
|
252
|
+
attrs = Record.tool_execution_update(attributes.merge(status: to))
|
|
253
|
+
affected = tool_execution_class.where(uid: id, status: from).update_all(attrs.merge(updated_at: Clock.now))
|
|
254
|
+
return nil if affected.zero?
|
|
255
|
+
|
|
256
|
+
load_tool_execution(id)
|
|
151
257
|
end
|
|
152
258
|
|
|
153
259
|
def list_tool_executions(turn_id:)
|
|
154
260
|
tool_execution_class.where(turn_uid: turn_id).order(:created_at, :uid).map { |record| tool_execution_hash(record) }
|
|
155
261
|
end
|
|
156
262
|
|
|
157
|
-
def find_stale_turns(before:)
|
|
158
|
-
turn_class.where(status: %w[pending running]).where("COALESCE(heartbeat_at, started_at, created_at) < ?", before).map { |record| turn_hash(record) }
|
|
159
|
-
end
|
|
160
|
-
|
|
161
263
|
private
|
|
162
264
|
def conversation_class = constantize(@conversation_class_name)
|
|
163
265
|
def turn_class = constantize(@turn_class_name)
|
|
164
266
|
def message_class = constantize(@message_class_name)
|
|
165
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)
|
|
166
270
|
|
|
167
271
|
def constantize(name)
|
|
168
272
|
name.to_s.split("::").inject(Object) { |mod, part| mod.const_get(part) }
|
|
@@ -182,7 +286,9 @@ module TurnKit
|
|
|
182
286
|
end
|
|
183
287
|
|
|
184
288
|
def conversation_hash(record)
|
|
185
|
-
{ "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 }
|
|
186
292
|
end
|
|
187
293
|
|
|
188
294
|
def turn_hash(record)
|
|
@@ -192,6 +298,7 @@ module TurnKit
|
|
|
192
298
|
"root_turn_id" => record.root_turn_uid, "context_message_sequence" => record.context_message_sequence,
|
|
193
299
|
"status" => record.status, "model" => record.model, "options" => record.options || {}, "usage" => record.usage || {},
|
|
194
300
|
"cost" => record.cost, "error" => record.error, "output_text" => record.output_text,
|
|
301
|
+
"submitted_at" => record.submitted_at, "claim_token" => record.claim_token,
|
|
195
302
|
"started_at" => record.started_at, "heartbeat_at" => record.heartbeat_at, "completed_at" => record.completed_at,
|
|
196
303
|
"created_at" => record.created_at, "updated_at" => record.updated_at
|
|
197
304
|
}
|
|
@@ -199,6 +306,19 @@ module TurnKit
|
|
|
199
306
|
attrs
|
|
200
307
|
end
|
|
201
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
|
+
|
|
202
322
|
def turn_has_attribute?(name)
|
|
203
323
|
turn_class.respond_to?(:attribute_names) && turn_class.attribute_names.include?(name)
|
|
204
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
|