turnkit 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +198 -5
  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 +124 -14
  13. data/lib/turnkit/adapters/ruby_llm.rb +107 -21
  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/client.rb +3 -1
  19. data/lib/turnkit/conversation.rb +54 -1
  20. data/lib/turnkit/coordination_tools.rb +67 -0
  21. data/lib/turnkit/cost.rb +7 -7
  22. data/lib/turnkit/error.rb +3 -0
  23. data/lib/turnkit/execution_store.rb +30 -0
  24. data/lib/turnkit/id.rb +1 -0
  25. data/lib/turnkit/image_tool.rb +10 -0
  26. data/lib/turnkit/job.rb +21 -0
  27. data/lib/turnkit/memory_store.rb +113 -20
  28. data/lib/turnkit/message_projection.rb +4 -1
  29. data/lib/turnkit/reconciliation.rb +13 -10
  30. data/lib/turnkit/record.rb +36 -3
  31. data/lib/turnkit/run.rb +28 -0
  32. data/lib/turnkit/skill.rb +5 -4
  33. data/lib/turnkit/specialists.rb +254 -0
  34. data/lib/turnkit/store.rb +38 -9
  35. data/lib/turnkit/sub_agent_tool.rb +23 -7
  36. data/lib/turnkit/system_prompt.rb +7 -7
  37. data/lib/turnkit/tool.rb +11 -0
  38. data/lib/turnkit/tool_runner.rb +109 -45
  39. data/lib/turnkit/turn.rb +238 -61
  40. data/lib/turnkit/turn_controls.rb +136 -0
  41. data/lib/turnkit/version.rb +1 -1
  42. data/lib/turnkit.rb +31 -0
  43. metadata +16 -5
data/lib/turnkit/turn.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  module TurnKit
4
4
  class Turn
5
+ include TurnControls
5
6
  STATUSES = Record::TURN_STATUSES
6
7
 
7
8
  attr_reader :agent, :conversation, :store, :budget, :depth
@@ -13,6 +14,7 @@ module TurnKit
13
14
  @agent = agent
14
15
  @conversation = conversation
15
16
  @store = store
17
+ @base_store = store
16
18
  @record = record.transform_keys(&:to_s)
17
19
  @id = @record.fetch("id")
18
20
  @conversation_id = @record.fetch("conversation_id")
@@ -36,62 +38,92 @@ module TurnKit
36
38
  @on_event = block if block
37
39
  return self unless status == "pending"
38
40
 
39
- claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
41
+ @store = @base_store
42
+ claimed = Background.claim(store, @record)
40
43
  return self unless claimed
41
44
 
42
45
  @record = claimed
43
46
  @started_at = @record["started_at"]
44
- emit("turn.started", status: status, model: model)
45
- agent.effective_client.validate!(model: model)
46
- @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
47
- revisions_used = 0
48
- loop do
49
- budget.check!(depth: depth)
50
- count_iteration!
51
- TurnKit::Compaction.maybe_compact!(self)
52
-
53
- request = model_request
54
- emit_model_requested("model.requested", request)
55
- result = call_client(request)
56
- result_cost = Cost.from_usage(result.usage, model: result.model || model)
57
-
58
- add_usage!(result.usage, cost: result_cost)
59
- emit_model_completed("model.completed", result, result_cost, model: model)
60
- budget.add_cost!(result_cost.total)
61
- persist_assistant_message(result)
62
-
63
- if result.tool_calls?
64
- runner = ToolRunner.new(self)
65
- terminal = runner.dispatch(result.tool_calls)
66
- if terminal
67
- candidate = append_terminal_completion(runner, terminal)
68
- else
69
- next
70
- end
71
- else
72
- candidate = result.text
73
- end
74
-
75
- audit = check_policy(candidate, output_data: result.output_data)
76
- if should_revise?(audit, revisions_used)
77
- revisions_used += 1
78
- append_revision_message(audit, attempt: revisions_used, terminal_tool_name: terminal&.tool_name)
79
- emit("output_policy.revision", violation_count: audit.violations.length, attempt: revisions_used)
80
- next
81
- end
82
-
83
- complete_with_output(candidate, output_data: result.output_data, audit: audit)
84
- break
47
+ fence!
48
+ with_heartbeat do
49
+ emit("turn.started", status: status, model: model)
50
+ agent.effective_client.validate!(model: model)
51
+ execute
85
52
  end
86
53
  reload
87
54
  self
55
+ rescue LostClaim
56
+ reload
88
57
  rescue StandardError => error
58
+ # Background infrastructure failures must reach the job backend. The
59
+ # reconciler recovers the persisted phase rather than guessing here.
60
+ raise if background? && !error.is_a?(TurnKit::Error)
61
+ raise unless claimed
62
+
89
63
  update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
90
64
  emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
91
65
  reload
92
66
  self
93
67
  end
94
68
 
69
+ def background? = !!@record["submitted_at"]
70
+
71
+ def perform_later(callback: nil)
72
+ Background.submit(self, callback: callback.respond_to?(:id) ? callback.id : callback)
73
+ end
74
+
75
+ def wait_for(*targets)
76
+ Background.wait(self, targets.flatten, transition: true)
77
+ reload
78
+ end
79
+
80
+ def suspend!
81
+ store.atomic do
82
+ reload
83
+ update!(status: @record.dig("options", "controls", "pause_requested") ? "paused" : "waiting", claim_token: nil)
84
+ end
85
+ end
86
+
87
+ # Revokes the local claim. An already-sent remote request cannot be
88
+ # recalled, but ExecutionStore fences every later write from that worker.
89
+ def cancel!(descendants: :retain, principal: nil)
90
+ raise ArgumentError, "descendants must be :retain or :cascade" unless %i[retain cascade].include?(descendants)
91
+ Authorization.authorize!(:cancel, principal: principal, turn: self, descendants: descendants)
92
+ @base_store.atomic(Background.root_conversation(@base_store, @record)) do
93
+ all = @base_store.list_turns(root_turn_id: root_turn_id)
94
+ rows = [@base_store.load_turn(id)]
95
+ if descendants == :cascade
96
+ descendant_ids = { id => true }
97
+ loop do
98
+ added = all.select { |row| row["parent_turn_id"] && descendant_ids[row["parent_turn_id"]] && !descendant_ids[row["id"]] }
99
+ break if added.empty?
100
+ added.each { |row| descendant_ids[row["id"]] = true }
101
+ end
102
+ rows.concat(all.select { |row| row["id"] != id && descendant_ids[row["id"]] })
103
+ end
104
+ rows.each do |row|
105
+ next if Background::TERMINAL.include?(row["status"])
106
+ executions = Reconciliation.interrupt_tool_executions(row, store: @base_store)
107
+ Reconciliation.repair_transcript(row, executions, store: @base_store)
108
+ attrs = { status: "cancelled", claim_token: nil, completed_at: Clock.now,
109
+ error: { "class" => "TurnKit::Cancelled", "message" => "cancelled by application" } }
110
+ terminal_row = @base_store.update_turn(row.fetch("id"), attrs)
111
+ Background.callback_after_terminal(@base_store, terminal_row) if row["submitted_at"]
112
+ end
113
+ rows.map { |row| row.fetch("conversation_id") }.uniq.each { |conversation_id| Background.wake(conversation_id, store: @base_store) } if background?
114
+ end
115
+ Background.enqueue if background?
116
+ reload
117
+ end
118
+
119
+ def execution_budget(excluding: nil)
120
+ root = store.load_turn(root_turn_id)
121
+ limits = root.dig("options", "budget_limits") || agent.budget_limits
122
+ turns = store.list_turns(root_turn_id: root_turn_id)
123
+ executions = turns.flat_map { |row| store.list_tool_executions(turn_id: row.fetch("id")) }.reject { |row| row["id"] == excluding }
124
+ Budget.new(**limits.transform_keys(&:to_sym), root_started_at: root["submitted_at"] || root["started_at"] || Clock.now).seed!(turns: turns, tool_executions: executions)
125
+ end
126
+
95
127
  def preview
96
128
  model_request
97
129
  end
@@ -112,6 +144,8 @@ module TurnKit
112
144
  @record["output_data"]
113
145
  end
114
146
 
147
+ def context = JSON.parse(JSON.generate(@record.dig("options", "context") || {}))
148
+
115
149
  def policy_audit
116
150
  options = @record["options"] || {}
117
151
  options.dig("state", "policy_audit") || options["policy_audit"]
@@ -180,6 +214,7 @@ module TurnKit
180
214
  if claimed
181
215
  @record = claimed
182
216
  @started_at = @record["started_at"]
217
+ fence!
183
218
  emit("turn.started", status: status, model: model)
184
219
  @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
185
220
  end
@@ -218,6 +253,7 @@ module TurnKit
218
253
  if claimed
219
254
  @record = claimed
220
255
  @started_at = @record["started_at"]
256
+ fence!
221
257
  emit("turn.started", status: status, model: model)
222
258
  @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
223
259
  end
@@ -252,6 +288,98 @@ module TurnKit
252
288
  end
253
289
 
254
290
  private
291
+ def fence!
292
+ @store = ExecutionStore.new(store, turn_id: id, token: @record.fetch("claim_token"), conversation_id: Background.root_conversation(store, @record))
293
+ @conversation = Conversation.new(agent: agent, record: store.load_conversation(conversation.id), store: store,
294
+ model: conversation.model, subject: conversation.subject, metadata: conversation.metadata)
295
+ end
296
+
297
+ def execute
298
+ loop do
299
+ control = control_boundary!
300
+ break if control == :paused
301
+ if control == :waiting
302
+ suspend!
303
+ break
304
+ end
305
+ @budget = execution_budget
306
+ budget.check!(depth: depth)
307
+ state = @record.dig("options", "state") || {}
308
+ case state["phase"] || "model"
309
+ when "model"
310
+ count_iteration!
311
+ TurnKit::Compaction.maybe_compact!(self)
312
+ request = model_request
313
+ emit_model_requested("model.requested", request)
314
+ control = control_boundary!
315
+ break if control == :paused
316
+ next if control
317
+ result = call_client(request)
318
+ cost = Cost.from_usage(result.usage, model: result.model || model)
319
+ store.atomic do
320
+ add_usage!(result.usage, cost: cost)
321
+ persist_assistant_message(result)
322
+ update_state!("phase" => result.tool_calls? ? "tools" : "output", "parts" => result.parts,
323
+ "candidate" => result.text, "output_data" => result.output_data, "terminal_tool_name" => nil)
324
+ end
325
+ emit_model_completed("model.completed", result, cost, model: model)
326
+ budget.add_cost!(cost.total)
327
+ when "tools"
328
+ runner = ToolRunner.new(self)
329
+ terminal = runner.dispatch(Result.new(parts: state.fetch("parts")).tool_calls)
330
+ break if terminal == :paused
331
+ next if terminal == :steered
332
+ if terminal == :waiting
333
+ suspend!
334
+ break
335
+ end
336
+ store.atomic do
337
+ if terminal
338
+ candidate = append_terminal_completion(runner, terminal)
339
+ update_state!("phase" => "output", "candidate" => candidate, "terminal_tool_name" => terminal.tool_name)
340
+ else
341
+ update_state!("phase" => "model", "parts" => nil)
342
+ end
343
+ end
344
+ when "output"
345
+ candidate = state.fetch("candidate")
346
+ audit = check_policy(candidate, output_data: state["output_data"])
347
+ revisions_used = state["revisions_used"].to_i
348
+ if should_revise?(audit, revisions_used)
349
+ store.atomic do
350
+ append_revision_message(audit, attempt: revisions_used + 1, terminal_tool_name: state["terminal_tool_name"])
351
+ update_state!("phase" => "model", "parts" => nil, "revisions_used" => revisions_used + 1)
352
+ end
353
+ emit("output_policy.revision", violation_count: audit.violations.length, attempt: revisions_used + 1)
354
+ else
355
+ complete_with_output(candidate, output_data: state["output_data"], audit: audit)
356
+ break unless status == "running"
357
+ end
358
+ end
359
+ end
360
+ end
361
+
362
+ def with_heartbeat
363
+ mutex, wake = Mutex.new, ConditionVariable.new
364
+ stopped = false
365
+ heartbeat = Thread.new do
366
+ loop do
367
+ break if mutex.synchronize {
368
+ wake.wait(mutex, (TurnKit.timeout || 300) / 3.0) unless stopped
369
+ stopped
370
+ }
371
+ heartbeat!
372
+ end
373
+ rescue LostClaim
374
+ # The executing thread observes the same revocation on its next write.
375
+ end
376
+ yield
377
+ ensure
378
+ # Never interrupt a database write/connection initialization mid-flight.
379
+ mutex.synchronize { stopped = true; wake.signal }
380
+ heartbeat&.value
381
+ end
382
+
255
383
  def model_request
256
384
  prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
257
385
  instructions, dynamic_instructions = case agent.system_prompt
@@ -265,12 +393,12 @@ module TurnKit
265
393
  ModelRequest.new(
266
394
  model: model,
267
395
  messages: llm_messages,
268
- tools: agent.effective_tools,
396
+ tools: agent.effective_tools(turn: self),
269
397
  instructions: instructions,
270
398
  dynamic_instructions: dynamic_instructions,
271
399
  thinking: thinking,
272
400
  output_schema: output_schema,
273
- metadata: { turn_id: id, conversation_id: conversation.id },
401
+ metadata: { turn_id: id, conversation_id: conversation.id, request_id: @record.dig("options", "state", "request_id") },
274
402
  report: prompt.report
275
403
  )
276
404
  end
@@ -291,15 +419,30 @@ module TurnKit
291
419
  end
292
420
 
293
421
  def call_image_client(client, request)
294
- client.paint(**request, on_event: ->(event) { emit_event(event) })
422
+ with_heartbeat { client.paint(**request, on_event: ->(event) { emit_event(event) }) }
295
423
  end
296
424
 
297
425
  def call_media_client(client, request)
298
- client.view_media(**request, on_event: ->(event) { emit_event(event) })
426
+ with_heartbeat { client.view_media(**request, on_event: ->(event) { emit_event(event) }) }
299
427
  end
300
428
 
301
429
  def llm_messages
302
- MessageProjection.for(TurnKit::Compaction.project(conversation.messages_for_turn(self)))
430
+ messages = TurnKit::Compaction.project(conversation.messages_for_turn(self))
431
+ # Delivery time is not application time. A next-turn message can arrive
432
+ # between an earlier turn's tool call/result or before its steering.
433
+ # Keep UI sequence order intact, but place each delivery at the frozen
434
+ # context boundary of its first receiving turn in the provider input.
435
+ turns = store.list_turns(conversation_id: conversation.id)
436
+ messages = messages.sort_by do |message|
437
+ delivery_id = message.metadata["delivery_id"]
438
+ receiver = if delivery_id
439
+ turns.find { |row| row.dig("options", "state", "delivery_requests", delivery_id) } ||
440
+ turns.find { |row| row["context_message_sequence"] >= message.sequence &&
441
+ (row["submitted_at"] || row["started_at"] || row["id"] == id) }
442
+ end
443
+ receiver ? [receiver.fetch("context_message_sequence"), 1, message.sequence] : [message.sequence, 0, 0]
444
+ end
445
+ MessageProjection.for(messages)
303
446
  end
304
447
 
305
448
  def emit_model_requested(type, request)
@@ -362,7 +505,7 @@ module TurnKit
362
505
  message = conversation.append_message(role: "assistant", kind: "media_analysis", content: result.media_analyses.map { |analysis| analysis.to_h.merge("type" => "media_analysis") }, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
363
506
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
364
507
  else
365
- message = conversation.append_message(role: "assistant", kind: "text", text: result.text, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
508
+ message = conversation.append_message(role: "assistant", kind: "text", content: result.parts, turn_id: id, metadata: { "output_data" => result.output_data }.compact)
366
509
  emit("message.created", message_id: message.id, role: message.role, kind: message.kind)
367
510
  end
368
511
  end
@@ -392,8 +535,15 @@ module TurnKit
392
535
  else
393
536
  attrs[:status] = "completed"
394
537
  end
395
- update!(attrs)
396
- persist_policy_audit(audit) if audit
538
+ controlled = store.atomic(Background.root_conversation(store, @record)) do
539
+ control = control_boundary!
540
+ next control if control
541
+ update_state!("policy_audit" => audit.to_h) if audit
542
+ update!(attrs)
543
+ nil
544
+ end
545
+ return if controlled
546
+ emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length) if audit
397
547
 
398
548
  if failed?
399
549
  emit("turn.failed", error: @record["error"])
@@ -410,11 +560,6 @@ module TurnKit
410
560
  TurnKit.check_output_policy(output, constraints: constraints, context: { turn: self, output_text: text, output_data: output_data })
411
561
  end
412
562
 
413
- def persist_policy_audit(audit)
414
- update_state!("policy_audit" => audit.to_h)
415
- emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length)
416
- end
417
-
418
563
  def should_revise?(audit, revisions_used)
419
564
  audit && !audit.clean? && revisions_used < agent.output_retries
420
565
  end
@@ -462,20 +607,35 @@ module TurnKit
462
607
  end
463
608
 
464
609
  def count_iteration!
465
- budget.count_iteration!
466
- update_state!("iterations" => Turn.iterations_for(@record) + 1)
610
+ store.atomic do
611
+ @budget = execution_budget
612
+ budget.count_iteration!
613
+ request_id = SecureRandom.uuid
614
+ options = store.load_turn(id).fetch("options")
615
+ controls = options["controls"] || {}
616
+ inputs = controls.fetch("inputs", []).map do |input|
617
+ input["message_id"] && !input["request_id"] ? input.merge("request_id" => request_id) : input
618
+ end
619
+ update!(options: options.merge("controls" => controls.merge("inputs" => inputs)))
620
+ deliveries = options.dig("state", "delivery_requests") || {}
621
+ conversation.messages_for_turn(self).each do |message|
622
+ delivery_id = message.metadata["delivery_id"]
623
+ deliveries[delivery_id] ||= request_id if delivery_id
624
+ end
625
+ update_state!("iterations" => Turn.iterations_for(@record) + 1, "request_id" => request_id, "delivery_requests" => deliveries)
626
+ end
467
627
  end
468
628
 
469
629
  # Runtime state lives under options["state"]; the rest of options is
470
630
  # write-once turn configuration. Reads fall back to the legacy top-level
471
631
  # keys for turns persisted before the split.
472
632
  def update_state!(changes)
473
- options = @record["options"] || {}
633
+ options = store.load_turn(id)["options"] || {}
474
634
  update!(options: options.merge("state" => (options["state"] || {}).merge(changes)))
475
635
  end
476
636
 
477
637
  def heartbeat!
478
- update!(heartbeat_at: Clock.now)
638
+ store.update_turn(id, heartbeat_at: Clock.now)
479
639
  end
480
640
 
481
641
  # Claims a pending turn for a standalone media call. Returns the claimed
@@ -486,7 +646,7 @@ module TurnKit
486
646
  def claim_standalone!(action)
487
647
  case status
488
648
  when "pending"
489
- claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
649
+ claimed = Background.claim(store, @record)
490
650
  raise Error, "turn is already running" unless claimed
491
651
 
492
652
  claimed
@@ -516,7 +676,21 @@ module TurnKit
516
676
  end
517
677
 
518
678
  def update!(attributes)
519
- @record = store.update_turn(id, attributes)
679
+ terminal = Background::TERMINAL.include?(attributes[:status])
680
+ store.atomic(Background.root_conversation(store, @record)) do
681
+ if terminal
682
+ if attributes[:status] == "failed" && background?
683
+ executions = Reconciliation.interrupt_tool_executions(@record, store: store)
684
+ Reconciliation.repair_transcript(@record, executions, store: store)
685
+ end
686
+ attributes = attributes.merge(claim_token: nil)
687
+ end
688
+ @record = store.update_turn(id, attributes)
689
+ # The terminal write clears this execution's claim token, so durable
690
+ # callback work must use the unfenced store after that write.
691
+ Background.callback_after_terminal(@base_store, @record) if terminal && background?
692
+ Background.wake(conversation.id, store: @base_store) if terminal && background?
693
+ end
520
694
  @started_at = @record["started_at"]
521
695
  @model = @record["model"] || agent.effective_model
522
696
  @record
@@ -535,5 +709,8 @@ module TurnKit
535
709
  @turn = turn
536
710
  @execution = execution
537
711
  end
712
+
713
+ def idempotency_key = "turnkit:tool:#{execution.id}"
714
+ def principal = turn.store.load_turn(turn.id).dig("options", "principal")
538
715
  end
539
716
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurnKit
4
+ # Human controls share the execution root lock. Options are the durable
5
+ # source of truth; no callbacks or job payloads are needed for recovery.
6
+ module TurnControls
7
+ def pause!(descendants: :retain, principal: nil)
8
+ control_tree(:pause, descendants, principal) do |row|
9
+ controls = row.dig("options", "controls") || {}
10
+ attrs = { options: row.fetch("options").merge("controls" => controls.merge("pause_requested" => true)) }
11
+ attrs[:status] = "paused" unless row["status"] == "running"
12
+ @base_store.update_turn(row.fetch("id"), attrs)
13
+ end
14
+ reload
15
+ end
16
+
17
+ def resume!(descendants: :retain, principal: nil)
18
+ control_tree(:resume, descendants, principal) do |row|
19
+ controls = row.dig("options", "controls") || {}
20
+ attrs = { options: row.fetch("options").merge("controls" => controls.merge("pause_requested" => false)) }
21
+ if row["status"] == "paused"
22
+ attrs[:status] = Background.ready?(@base_store, row.fetch("id")) ? "pending" : "waiting"
23
+ end
24
+ @base_store.update_turn(row.fetch("id"), attrs)
25
+ end
26
+ Background.enqueue if background?
27
+ reload
28
+ end
29
+
30
+ def steer!(text, key:, principal: nil, descendants: :retain)
31
+ raise ArgumentError, "key must be a nonempty string" unless key.is_a?(String) && !key.empty?
32
+ text = text.to_s
33
+ principal = JSON.parse(JSON.generate(principal))
34
+ receipts = []
35
+ control_tree(:steer, descendants, principal) do |row|
36
+ controls = row.dig("options", "controls") || {}
37
+ inputs = controls.fetch("inputs", [])
38
+ existing = inputs.find { |input| input["key"] == key }
39
+ if existing
40
+ unless existing["text"] == text && existing["principal"] == principal
41
+ raise ToolError, "steering key is already used for a different input"
42
+ end
43
+ receipts << existing
44
+ next
45
+ end
46
+ if Background::TERMINAL.include?(row["status"]) || row["status"] == "stale"
47
+ raise Error, "cannot steer a #{row['status']} turn; post next-turn input instead" if row["id"] == id
48
+ next
49
+ end
50
+ input = { "id" => SecureRandom.uuid, "key" => key, "text" => text,
51
+ "principal" => principal, "turn_id" => row.fetch("id"), "sequence" => inputs.length + 1 }
52
+ @base_store.update_turn(row.fetch("id"), options: row.fetch("options").merge(
53
+ "controls" => controls.merge("inputs" => inputs + [input])))
54
+ receipts << input
55
+ end
56
+ receipts
57
+ end
58
+
59
+ def control_state(principal: nil)
60
+ Authorization.authorize!(:read_control, principal: principal, turn: self)
61
+ row = @base_store.load_turn(id)
62
+ { "status" => row.fetch("status"), "controls" => row.dig("options", "controls") || {} }
63
+ end
64
+
65
+ # Called before dispatching another unit of work, never during a remote
66
+ # call. The lock acquisition is the dispatch/control linearization point.
67
+ def control_boundary!
68
+ store.atomic do
69
+ reload
70
+ if @record.dig("options", "controls", "pause_requested")
71
+ update!(status: "paused", claim_token: nil)
72
+ next :paused
73
+ end
74
+ inputs = @record.dig("options", "controls", "inputs") || []
75
+ pending = inputs.reject { |input| input["message_id"] }
76
+ next unless pending.any?
77
+ unless Background.ready?(store, id)
78
+ next Background.deadline_exceeded?(store, @record) ? nil : :waiting
79
+ end
80
+
81
+ executions = store.list_tool_executions(turn_id: id)
82
+ parts = @record.dig("options", "state", "parts") || []
83
+ parts.select { |part| part["type"] == "tool_call" }.each do |part|
84
+ execution = executions.find { |row| row["tool_call_id"] == part["id"] }
85
+ child = store.list_turns(root_turn_id: root_turn_id).find { |row| execution && row["parent_tool_execution_id"] == execution["id"] }
86
+ if child && Background::TERMINAL.include?(child["status"]) && %w[pending running].include?(execution["status"])
87
+ store.claim_tool_execution(execution.fetch("id"), from: execution.fetch("status"), to: "completed",
88
+ result: SubAgentTool.result(child), completed_at: Clock.now)
89
+ elsif !execution
90
+ store.create_tool_execution("turn_id" => id, "tool_call_id" => part.fetch("id"), "tool_name" => part.fetch("name"),
91
+ "arguments" => part["arguments"], "status" => "cancelled", "completed_at" => Clock.now,
92
+ "result" => { "skipped" => true, "message" => "not executed: superseded by human steering" })
93
+ end
94
+ end
95
+ executions = Reconciliation.interrupt_tool_executions(@record, store: store)
96
+ # Complete known results and close unexecuted proposals before adding
97
+ # human input: providers require a result for every assistant tool ID.
98
+ Reconciliation.repair_transcript(@record, executions, store: store)
99
+ pending.each do |input|
100
+ message = conversation.append_message(role: "user", kind: "text", text: input.fetch("text"), turn_id: id,
101
+ metadata: { "steering_id" => input.fetch("id"), "principal" => input["principal"] })
102
+ input["message_id"] = message.id
103
+ end
104
+ options = @record.fetch("options")
105
+ update!(options: options.merge("controls" => options.fetch("controls").merge("inputs" => inputs)))
106
+ update_state!("phase" => "model", "parts" => nil, "candidate" => nil, "output_data" => nil, "terminal_tool_name" => nil)
107
+ :steered
108
+ end
109
+ end
110
+
111
+ private
112
+ def control_tree(action, descendants, principal)
113
+ raise ArgumentError, "descendants must be :retain or :cascade" unless %i[retain cascade].include?(descendants)
114
+ @base_store.atomic(Background.root_conversation(@base_store, @record)) do
115
+ rows = [@base_store.load_turn(id)]
116
+ if descendants == :cascade
117
+ all = @base_store.list_turns(root_turn_id: root_turn_id)
118
+ loop do
119
+ ids = rows.map { |row| row.fetch("id") }
120
+ added = all.select { |row| ids.include?(row["parent_turn_id"]) && !ids.include?(row["id"]) }
121
+ break if added.empty?
122
+ rows.concat(added)
123
+ end
124
+ end
125
+ rows.each do |row|
126
+ Authorization.authorize!(action, principal: principal,
127
+ turn: row["id"] == id ? self : Background.load_turn(row.fetch("id"), store: @base_store), descendants: descendants)
128
+ end
129
+ rows.each do |row|
130
+ next if action != :steer && (Background::TERMINAL.include?(row["status"]) || row["status"] == "stale")
131
+ yield row
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurnKit
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/turnkit.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "turnkit/budget"
16
16
  require_relative "turnkit/event"
17
17
  require_relative "turnkit/model_request"
18
18
  require_relative "turnkit/schema_check"
19
+ require_relative "turnkit/authorization"
19
20
  require_relative "turnkit/agent"
20
21
  require_relative "turnkit/client"
21
22
  require_relative "turnkit/conversation"
@@ -44,12 +45,17 @@ require_relative "turnkit/sub_agent_tool"
44
45
  require_relative "turnkit/load_skill_tool"
45
46
  require_relative "turnkit/message_projection"
46
47
  require_relative "turnkit/tool_runner"
48
+ require_relative "turnkit/turn_controls"
47
49
  require_relative "turnkit/turn"
48
50
  require_relative "turnkit/usage"
49
51
  require_relative "turnkit/run"
50
52
  require_relative "turnkit/adapters/codex"
51
53
  require_relative "turnkit/adapters/ruby_llm"
52
54
  require_relative "turnkit/active_record_store"
55
+ require_relative "turnkit/execution_store"
56
+ require_relative "turnkit/background"
57
+ require_relative "turnkit/coordination_tools"
58
+ require_relative "turnkit/specialists"
53
59
 
54
60
  module TurnKit
55
61
  class << self
@@ -63,6 +69,29 @@ module TurnKit
63
69
  attr_accessor :prompt_sections, :prompt_behavior, :available_skills
64
70
  attr_accessor :prompt_data_max_chars, :context_contributors
65
71
  attr_accessor :on_event
72
+ attr_accessor :job_dispatcher
73
+ attr_accessor :authorization_policy, :maintenance_batch_size
74
+ attr_reader :agents
75
+ end
76
+
77
+ @agents = {}
78
+
79
+ def self.register(agent)
80
+ @agents[agent.name] = agent
81
+ agent.sub_agents.each { |child| register(child) }
82
+ agent
83
+ end
84
+
85
+ def self.resolve_agent(name)
86
+ @agents.fetch(name.to_s) { raise ConfigError, "register agent #{name.inspect} in every worker at boot" }
87
+ end
88
+
89
+ def self.load_turn(id)
90
+ Background.load_turn(id)
91
+ end
92
+
93
+ def self.load_conversation(id)
94
+ Background.load_conversation(id)
66
95
  end
67
96
 
68
97
  self.default_model = "claude-sonnet-4-5"
@@ -84,6 +113,8 @@ module TurnKit
84
113
  self.on_event = nil
85
114
  self.output_policy_model = nil
86
115
  self.output_policy_thinking = { effort: :low }
116
+ self.authorization_policy = nil
117
+ self.maintenance_batch_size = 100
87
118
 
88
119
  def self.configure
89
120
  yield self