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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +197 -1
  4. data/UPGRADE.md +57 -0
  5. data/lib/generators/turnkit/install/templates/create_turnkit_tables.rb +30 -0
  6. data/lib/generators/turnkit/install/templates/delivery.rb +7 -0
  7. data/lib/generators/turnkit/install/templates/initializer.rb +5 -0
  8. data/lib/generators/turnkit/install/templates/wait.rb +7 -0
  9. data/lib/generators/turnkit/install_generator.rb +2 -0
  10. data/lib/generators/turnkit/upgrade/templates/add_turnkit_durable_orchestration.rb +36 -0
  11. data/lib/generators/turnkit/upgrade_generator.rb +34 -0
  12. data/lib/turnkit/active_record_store.rb +130 -10
  13. data/lib/turnkit/adapters/ruby_llm.rb +14 -0
  14. data/lib/turnkit/agent.rb +26 -20
  15. data/lib/turnkit/authorization.rb +17 -0
  16. data/lib/turnkit/background.rb +281 -0
  17. data/lib/turnkit/budget.rb +4 -3
  18. data/lib/turnkit/conversation.rb +23 -1
  19. data/lib/turnkit/coordination_tools.rb +61 -0
  20. data/lib/turnkit/error.rb +3 -0
  21. data/lib/turnkit/execution_store.rb +30 -0
  22. data/lib/turnkit/id.rb +1 -0
  23. data/lib/turnkit/image_tool.rb +10 -0
  24. data/lib/turnkit/job.rb +21 -0
  25. data/lib/turnkit/memory_store.rb +110 -14
  26. data/lib/turnkit/reconciliation.rb +75 -0
  27. data/lib/turnkit/record.rb +37 -4
  28. data/lib/turnkit/run.rb +15 -0
  29. data/lib/turnkit/skill.rb +5 -4
  30. data/lib/turnkit/specialists.rb +254 -0
  31. data/lib/turnkit/store.rb +42 -2
  32. data/lib/turnkit/sub_agent_tool.rb +23 -7
  33. data/lib/turnkit/system_prompt.rb +7 -7
  34. data/lib/turnkit/tool.rb +11 -0
  35. data/lib/turnkit/tool_runner.rb +109 -29
  36. data/lib/turnkit/turn.rb +188 -57
  37. data/lib/turnkit/version.rb +1 -1
  38. data/lib/turnkit.rb +32 -3
  39. 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 update_tool_execution(id, attributes)
148
- record = tool_execution_class.find_by!(uid: id)
149
- record.update!(Record.tool_execution_update(attributes))
150
- tool_execution_hash(record)
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, "metadata" => record.metadata || {}, "created_at" => record.created_at, "updated_at" => record.updated_at }
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
- configured = tools + sub_agents.map { |agent| SubAgentTool.for(agent) }
142
- skills = effective_available_skills
143
- skills.empty? ? configured : configured + [ LoadSkillTool.for(skills) ]
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
- (Array(TurnKit.available_skills) + available_skills).uniq { |skill| skill.key }
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.each do |tool|
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 = effective_tools.map(&:tool_name)
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
- effective_tools.each(&:validate_definition!)
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