turnkit 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +187 -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 +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 +106 -15
  26. data/lib/turnkit/reconciliation.rb +13 -10
  27. data/lib/turnkit/record.rb +36 -3
  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 +38 -9
  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 +101 -45
  36. data/lib/turnkit/turn.rb +188 -57
  37. data/lib/turnkit/version.rb +1 -1
  38. data/lib/turnkit.rb +30 -0
  39. metadata +15 -5
@@ -7,14 +7,22 @@ module TurnKit
7
7
  end
8
8
 
9
9
  def dispatch(tool_calls)
10
+ waiting = false
10
11
  tool_calls.each_with_index do |tool_call, index|
11
- execution = run(tool_call)
12
+ # Fan out a contiguous group of subagents, but never reorder ordinary
13
+ # tools across it or execute past a terminal tool.
14
+ return :waiting if waiting && !subagent?(tool_for(tool_call.name))
15
+ execution = run(tool_call, defer_result: waiting)
16
+ if execution == :waiting
17
+ waiting = true
18
+ next
19
+ end
12
20
  if execution.completed? && tool_for(tool_call.name)&.ends_turn?
13
21
  skip_remaining(tool_calls.drop(index + 1), terminal: tool_call)
14
22
  return execution
15
23
  end
16
24
  end
17
- nil
25
+ waiting ? :waiting : nil
18
26
  end
19
27
 
20
28
  def completion_message(execution)
@@ -25,11 +33,30 @@ module TurnKit
25
33
  private
26
34
  attr_reader :turn
27
35
 
28
- def run(tool_call)
29
- execution = ToolExecution.new(create_execution(tool_call))
30
- heartbeat!
31
-
36
+ def run(tool_call, defer_result: false)
37
+ @defer_result = defer_result
32
38
  tool = tool_for(tool_call.name)
39
+ existing = turn.store.list_tool_executions(turn_id: turn.id).find { |row| row["tool_call_id"] == tool_call.id }
40
+ if existing && !%w[pending running].include?(existing["status"])
41
+ execution = ToolExecution.new(existing)
42
+ append_result_once(execution, tool_call, execution.result || execution.error, error: !execution.completed? && !execution.cancelled?)
43
+ return execution
44
+ end
45
+
46
+ denied = nil
47
+ execution = turn.store.atomic do
48
+ execution = ToolExecution.new(existing || create_execution(tool_call))
49
+ unless existing
50
+ begin
51
+ turn.execution_budget(excluding: execution.id).count_tool_execution!(tool_call.name)
52
+ rescue BudgetError => error
53
+ denied = error
54
+ finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
55
+ end
56
+ end
57
+ execution
58
+ end
59
+ raise denied if denied
33
60
 
34
61
  unless tool
35
62
  return finish_error(execution, tool_call, "unknown tool: #{tool_call.name}")
@@ -39,20 +66,33 @@ module TurnKit
39
66
  return finish_error(execution, tool_call, tool_call.arguments_error)
40
67
  end
41
68
 
42
- begin
43
- turn.budget.count_tool_execution!(tool_call.name)
44
- rescue BudgetError => error
45
- finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
46
- raise
69
+ if execution.status == "pending" && !subagent?(tool) && ![WaitTool, LaunchAgentTool, SendMessageTool].include?(tool)
70
+ claimed = turn.store.claim_tool_execution(execution.id, from: "pending", to: "running", started_at: Clock.now)
71
+ raise LostClaim, "tool execution claim was revoked" unless claimed
72
+ execution = ToolExecution.new(claimed)
47
73
  end
48
74
 
49
75
  context = ToolContext.new(turn: turn, execution: execution)
50
76
  payload = begin
51
- normalize_payload(call_tool(tool, tool_call.arguments, context: context))
77
+ Authorization.authorize!(:tool, principal: context.principal, turn: turn, tool: tool, arguments: tool_call.arguments)
78
+ # Observe cancellation/reconciliation immediately before crossing the
79
+ # external-effect boundary. Calls already sent cannot be recalled.
80
+ turn.store.atomic { true }
81
+ if turn.background? && subagent?(tool)
82
+ return delegate(tool, tool_call, context)
83
+ end
84
+ value = call_tool(tool, tool_call.arguments, context: context)
85
+ return :waiting if value == :waiting && tool == WaitTool
86
+ normalize_payload(value)
87
+ rescue LostClaim
88
+ raise
52
89
  rescue BudgetError => error
53
90
  finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
54
91
  raise
92
+ rescue AuthorizationError => error
93
+ return finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "authorization_denied" => true })
55
94
  rescue StandardError => error
95
+ raise if turn.background? && !error.is_a?(ToolError)
56
96
  return finish_error(execution, tool_call, error.message, details: { "class" => error.class.name })
57
97
  end
58
98
  finish_success(execution, tool_call, payload)
@@ -63,7 +103,7 @@ module TurnKit
63
103
  "turn_id" => turn.id,
64
104
  "tool_call_id" => tool_call.id,
65
105
  "tool_name" => tool_call.name,
66
- "status" => "running",
106
+ "status" => turn.background? && (subagent?(tool_for(tool_call.name)) || [WaitTool, LaunchAgentTool, SendMessageTool].include?(tool_for(tool_call.name))) ? "pending" : "running",
67
107
  "arguments" => tool_call.arguments,
68
108
  "started_at" => Clock.now
69
109
  )
@@ -71,11 +111,12 @@ module TurnKit
71
111
 
72
112
  def finish_success(execution, tool_call, payload)
73
113
  json = payload.to_json
74
- attrs = turn.store.claim_tool_execution(execution.id, from: "running", to: "completed", result: payload, completed_at: Clock.now)
114
+ attrs = turn.store.atomic do
115
+ row = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "completed", result: payload, completed_at: Clock.now)
116
+ append_result_once(execution, tool_call, payload) if row
117
+ row
118
+ end
75
119
  return superseded_execution(execution) unless attrs
76
-
77
- append_result(execution, tool_call, payload, json: json, error: false)
78
- heartbeat!
79
120
  turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: json.length)
80
121
  ToolExecution.new(attrs)
81
122
  end
@@ -83,11 +124,12 @@ module TurnKit
83
124
  def finish_error(execution, tool_call, message, details: nil)
84
125
  error = { "message" => message.to_s, "details" => details }.compact
85
126
  json = error.to_json
86
- attrs = turn.store.claim_tool_execution(execution.id, from: "running", to: "failed", error: error, completed_at: Clock.now)
127
+ attrs = turn.store.atomic do
128
+ row = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "failed", error: error, completed_at: Clock.now)
129
+ append_result_once(execution, tool_call, error, error: true) if row
130
+ row
131
+ end
87
132
  return superseded_execution(execution) unless attrs
88
-
89
- append_result(execution, tool_call, error, json: json, error: true)
90
- heartbeat!
91
133
  turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: json.length)
92
134
  ToolExecution.new(attrs)
93
135
  end
@@ -98,11 +140,14 @@ module TurnKit
98
140
  ToolExecution.new(turn.store.load_tool_execution(execution.id))
99
141
  end
100
142
 
101
- def append_result(execution, tool_call, payload, json: payload.to_json, error: false)
143
+ def append_result_once(execution, tool_call, payload, error: false)
144
+ return if @defer_result
145
+ return if turn.store.list_messages(turn.conversation.id).any? { |row| row["tool_execution_id"] == execution.id }
146
+
102
147
  message = turn.conversation.append_message(
103
148
  role: "tool",
104
149
  kind: "tool_result",
105
- content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => json, "error" => error } ],
150
+ content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => payload.to_json, "error" => error } ],
106
151
  turn_id: turn.id,
107
152
  tool_execution_id: execution.id,
108
153
  metadata: { "tool_name" => tool_call.name }
@@ -112,42 +157,53 @@ module TurnKit
112
157
 
113
158
  def skip_remaining(calls, terminal:)
114
159
  calls.each do |call|
115
- payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
116
- execution = ToolExecution.new(create_execution(call))
117
- attrs = turn.store.claim_tool_execution(execution.id, from: "running", to: "cancelled", result: payload, completed_at: Clock.now)
118
- next unless attrs
119
-
120
- append_result(ToolExecution.new(attrs), call, payload)
121
- turn.emit("tool_call.skipped", id: call.id, name: call.name)
160
+ turn.store.atomic do
161
+ next if turn.store.list_tool_executions(turn_id: turn.id).any? { |row| row["tool_call_id"] == call.id }
162
+ payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
163
+ execution = ToolExecution.new(create_execution(call))
164
+ attrs = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "cancelled", result: payload, completed_at: Clock.now)
165
+ append_result_once(ToolExecution.new(attrs), call, payload)
166
+ turn.emit("tool_call.skipped", id: call.id, name: call.name)
167
+ end
122
168
  end
123
169
  end
124
170
 
125
- def heartbeat!
126
- turn.send(:heartbeat!)
171
+ def subagent?(tool)
172
+ tool.is_a?(Class) && tool < SubAgentTool
173
+ end
174
+
175
+ def delegate(tool, call, context)
176
+ arguments = tool.validate_arguments(call.arguments)
177
+ Authorization.authorize!(:launch_agent, principal: context.principal, turn: turn, agent: tool.agent, arguments: arguments)
178
+ TurnKit.resolve_agent(tool.agent.name)
179
+ child = turn.store.atomic_graph do
180
+ turn.store.atomic(Background.root_conversation(turn.store, turn.store.load_turn(turn.id))) do
181
+ row = turn.store.list_turns(root_turn_id: turn.root_turn_id).find { |candidate| candidate["parent_tool_execution_id"] == context.execution.id }
182
+ unless row
183
+ built = tool.build_child(task: arguments.fetch("task"), context: context)
184
+ row = turn.store.update_turn(built.id, submitted_at: Clock.now)
185
+ end
186
+ Background.wait(turn, [row.fetch("id")])
187
+ row
188
+ end
189
+ end
190
+ unless Background::TERMINAL.include?(child["status"])
191
+ Background.enqueue(child.fetch("id")) if child["status"] == "pending"
192
+ return :waiting
193
+ end
194
+ finish_success(context.execution, call, SubAgentTool.result(child))
127
195
  end
128
196
 
129
197
  def tool_for(name)
130
- turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
198
+ turn.agent.effective_tools(turn: turn).find { |tool| tool.tool_name == name.to_s }
131
199
  end
132
200
 
133
- # Heartbeats while the tool runs so a tool slower than TurnKit.timeout
134
- # keeps its turn's stale anchor fresh and is not falsely reconciled.
135
201
  def call_tool(tool, arguments, context:)
136
- interval = (TurnKit.timeout || 300) / 3.0
137
- heartbeat = Thread.new do
138
- loop do
139
- sleep interval
140
- heartbeat!
141
- end
142
- end
143
-
144
202
  if tool.is_a?(Class)
145
203
  tool.call(arguments, context: context)
146
204
  else
147
205
  tool.class.invoke(tool, arguments, context: context)
148
206
  end
149
- ensure
150
- heartbeat.kill
151
207
  end
152
208
 
153
209
  def normalize_payload(value)
data/lib/turnkit/turn.rb CHANGED
@@ -13,6 +13,7 @@ module TurnKit
13
13
  @agent = agent
14
14
  @conversation = conversation
15
15
  @store = store
16
+ @base_store = store
16
17
  @record = record.transform_keys(&:to_s)
17
18
  @id = @record.fetch("id")
18
19
  @conversation_id = @record.fetch("conversation_id")
@@ -36,62 +37,89 @@ module TurnKit
36
37
  @on_event = block if block
37
38
  return self unless status == "pending"
38
39
 
39
- claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
40
+ @store = @base_store
41
+ claimed = Background.claim(store, @record)
40
42
  return self unless claimed
41
43
 
42
44
  @record = claimed
43
45
  @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
46
+ fence!
47
+ with_heartbeat do
48
+ emit("turn.started", status: status, model: model)
49
+ agent.effective_client.validate!(model: model)
50
+ execute
85
51
  end
86
52
  reload
87
53
  self
54
+ rescue LostClaim
55
+ reload
88
56
  rescue StandardError => error
57
+ # Background infrastructure failures must reach the job backend. The
58
+ # reconciler recovers the persisted phase rather than guessing here.
59
+ raise if background? && !error.is_a?(TurnKit::Error)
60
+ raise unless claimed
61
+
89
62
  update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
90
63
  emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
91
64
  reload
92
65
  self
93
66
  end
94
67
 
68
+ def background? = !!@record["submitted_at"]
69
+
70
+ def perform_later(callback: nil)
71
+ Background.submit(self, callback: callback.respond_to?(:id) ? callback.id : callback)
72
+ end
73
+
74
+ def wait_for(*targets)
75
+ Background.wait(self, targets.flatten, transition: true)
76
+ reload
77
+ end
78
+
79
+ def suspend!
80
+ update!(status: "waiting", claim_token: nil)
81
+ end
82
+
83
+ # Revokes the local claim. An already-sent remote request cannot be
84
+ # recalled, but ExecutionStore fences every later write from that worker.
85
+ def cancel!(descendants: :retain, principal: nil)
86
+ raise ArgumentError, "descendants must be :retain or :cascade" unless %i[retain cascade].include?(descendants)
87
+ Authorization.authorize!(:cancel, principal: principal, turn: self, descendants: descendants)
88
+ @base_store.atomic(Background.root_conversation(@base_store, @record)) do
89
+ all = @base_store.list_turns(root_turn_id: root_turn_id)
90
+ rows = [@base_store.load_turn(id)]
91
+ if descendants == :cascade
92
+ descendant_ids = { id => true }
93
+ loop do
94
+ added = all.select { |row| row["parent_turn_id"] && descendant_ids[row["parent_turn_id"]] && !descendant_ids[row["id"]] }
95
+ break if added.empty?
96
+ added.each { |row| descendant_ids[row["id"]] = true }
97
+ end
98
+ rows.concat(all.select { |row| row["id"] != id && descendant_ids[row["id"]] })
99
+ end
100
+ rows.each do |row|
101
+ next if Background::TERMINAL.include?(row["status"])
102
+ executions = Reconciliation.interrupt_tool_executions(row, store: @base_store)
103
+ Reconciliation.repair_transcript(row, executions, store: @base_store)
104
+ attrs = { status: "cancelled", claim_token: nil, completed_at: Clock.now,
105
+ error: { "class" => "TurnKit::Cancelled", "message" => "cancelled by application" } }
106
+ terminal_row = @base_store.update_turn(row.fetch("id"), attrs)
107
+ Background.callback_after_terminal(@base_store, terminal_row) if row["submitted_at"]
108
+ end
109
+ rows.map { |row| row.fetch("conversation_id") }.uniq.each { |conversation_id| Background.wake(conversation_id, store: @base_store) } if background?
110
+ end
111
+ Background.enqueue if background?
112
+ reload
113
+ end
114
+
115
+ def execution_budget(excluding: nil)
116
+ root = store.load_turn(root_turn_id)
117
+ limits = root.dig("options", "budget_limits") || agent.budget_limits
118
+ turns = store.list_turns(root_turn_id: root_turn_id)
119
+ executions = turns.flat_map { |row| store.list_tool_executions(turn_id: row.fetch("id")) }.reject { |row| row["id"] == excluding }
120
+ Budget.new(**limits.transform_keys(&:to_sym), root_started_at: root["submitted_at"] || root["started_at"] || Clock.now).seed!(turns: turns, tool_executions: executions)
121
+ end
122
+
95
123
  def preview
96
124
  model_request
97
125
  end
@@ -112,6 +140,8 @@ module TurnKit
112
140
  @record["output_data"]
113
141
  end
114
142
 
143
+ def context = JSON.parse(JSON.generate(@record.dig("options", "context") || {}))
144
+
115
145
  def policy_audit
116
146
  options = @record["options"] || {}
117
147
  options.dig("state", "policy_audit") || options["policy_audit"]
@@ -180,6 +210,7 @@ module TurnKit
180
210
  if claimed
181
211
  @record = claimed
182
212
  @started_at = @record["started_at"]
213
+ fence!
183
214
  emit("turn.started", status: status, model: model)
184
215
  @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
185
216
  end
@@ -218,6 +249,7 @@ module TurnKit
218
249
  if claimed
219
250
  @record = claimed
220
251
  @started_at = @record["started_at"]
252
+ fence!
221
253
  emit("turn.started", status: status, model: model)
222
254
  @budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
223
255
  end
@@ -252,6 +284,87 @@ module TurnKit
252
284
  end
253
285
 
254
286
  private
287
+ def fence!
288
+ @store = ExecutionStore.new(store, turn_id: id, token: @record.fetch("claim_token"), conversation_id: Background.root_conversation(store, @record))
289
+ @conversation = Conversation.new(agent: agent, record: store.load_conversation(conversation.id), store: store,
290
+ model: conversation.model, subject: conversation.subject, metadata: conversation.metadata)
291
+ end
292
+
293
+ def execute
294
+ loop do
295
+ @budget = execution_budget
296
+ budget.check!(depth: depth)
297
+ state = @record.dig("options", "state") || {}
298
+ case state["phase"] || "model"
299
+ when "model"
300
+ count_iteration!
301
+ TurnKit::Compaction.maybe_compact!(self)
302
+ request = model_request
303
+ emit_model_requested("model.requested", request)
304
+ result = call_client(request)
305
+ cost = Cost.from_usage(result.usage, model: result.model || model)
306
+ store.atomic do
307
+ add_usage!(result.usage, cost: cost)
308
+ persist_assistant_message(result)
309
+ update_state!("phase" => result.tool_calls? ? "tools" : "output", "parts" => result.parts,
310
+ "candidate" => result.text, "output_data" => result.output_data, "terminal_tool_name" => nil)
311
+ end
312
+ emit_model_completed("model.completed", result, cost, model: model)
313
+ budget.add_cost!(cost.total)
314
+ when "tools"
315
+ runner = ToolRunner.new(self)
316
+ terminal = runner.dispatch(Result.new(parts: state.fetch("parts")).tool_calls)
317
+ if terminal == :waiting
318
+ suspend!
319
+ break
320
+ end
321
+ store.atomic do
322
+ if terminal
323
+ candidate = append_terminal_completion(runner, terminal)
324
+ update_state!("phase" => "output", "candidate" => candidate, "terminal_tool_name" => terminal.tool_name)
325
+ else
326
+ update_state!("phase" => "model", "parts" => nil)
327
+ end
328
+ end
329
+ when "output"
330
+ candidate = state.fetch("candidate")
331
+ audit = check_policy(candidate, output_data: state["output_data"])
332
+ revisions_used = state["revisions_used"].to_i
333
+ if should_revise?(audit, revisions_used)
334
+ store.atomic do
335
+ append_revision_message(audit, attempt: revisions_used + 1, terminal_tool_name: state["terminal_tool_name"])
336
+ update_state!("phase" => "model", "parts" => nil, "revisions_used" => revisions_used + 1)
337
+ end
338
+ emit("output_policy.revision", violation_count: audit.violations.length, attempt: revisions_used + 1)
339
+ else
340
+ complete_with_output(candidate, output_data: state["output_data"], audit: audit)
341
+ break
342
+ end
343
+ end
344
+ end
345
+ end
346
+
347
+ def with_heartbeat
348
+ mutex, wake = Mutex.new, ConditionVariable.new
349
+ stopped = false
350
+ heartbeat = Thread.new do
351
+ loop do
352
+ break if mutex.synchronize {
353
+ wake.wait(mutex, (TurnKit.timeout || 300) / 3.0) unless stopped
354
+ stopped
355
+ }
356
+ heartbeat!
357
+ end
358
+ rescue LostClaim
359
+ # The executing thread observes the same revocation on its next write.
360
+ end
361
+ yield
362
+ ensure
363
+ # Never interrupt a database write/connection initialization mid-flight.
364
+ mutex.synchronize { stopped = true; wake.signal }
365
+ heartbeat&.value
366
+ end
367
+
255
368
  def model_request
256
369
  prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
257
370
  instructions, dynamic_instructions = case agent.system_prompt
@@ -265,7 +378,7 @@ module TurnKit
265
378
  ModelRequest.new(
266
379
  model: model,
267
380
  messages: llm_messages,
268
- tools: agent.effective_tools,
381
+ tools: agent.effective_tools(turn: self),
269
382
  instructions: instructions,
270
383
  dynamic_instructions: dynamic_instructions,
271
384
  thinking: thinking,
@@ -291,11 +404,11 @@ module TurnKit
291
404
  end
292
405
 
293
406
  def call_image_client(client, request)
294
- client.paint(**request, on_event: ->(event) { emit_event(event) })
407
+ with_heartbeat { client.paint(**request, on_event: ->(event) { emit_event(event) }) }
295
408
  end
296
409
 
297
410
  def call_media_client(client, request)
298
- client.view_media(**request, on_event: ->(event) { emit_event(event) })
411
+ with_heartbeat { client.view_media(**request, on_event: ->(event) { emit_event(event) }) }
299
412
  end
300
413
 
301
414
  def llm_messages
@@ -392,8 +505,11 @@ module TurnKit
392
505
  else
393
506
  attrs[:status] = "completed"
394
507
  end
395
- update!(attrs)
396
- persist_policy_audit(audit) if audit
508
+ store.atomic(Background.root_conversation(store, @record)) do
509
+ update_state!("policy_audit" => audit.to_h) if audit
510
+ update!(attrs)
511
+ end
512
+ emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length) if audit
397
513
 
398
514
  if failed?
399
515
  emit("turn.failed", error: @record["error"])
@@ -410,11 +526,6 @@ module TurnKit
410
526
  TurnKit.check_output_policy(output, constraints: constraints, context: { turn: self, output_text: text, output_data: output_data })
411
527
  end
412
528
 
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
529
  def should_revise?(audit, revisions_used)
419
530
  audit && !audit.clean? && revisions_used < agent.output_retries
420
531
  end
@@ -462,8 +573,11 @@ module TurnKit
462
573
  end
463
574
 
464
575
  def count_iteration!
465
- budget.count_iteration!
466
- update_state!("iterations" => Turn.iterations_for(@record) + 1)
576
+ store.atomic do
577
+ @budget = execution_budget
578
+ budget.count_iteration!
579
+ update_state!("iterations" => Turn.iterations_for(@record) + 1)
580
+ end
467
581
  end
468
582
 
469
583
  # Runtime state lives under options["state"]; the rest of options is
@@ -475,7 +589,7 @@ module TurnKit
475
589
  end
476
590
 
477
591
  def heartbeat!
478
- update!(heartbeat_at: Clock.now)
592
+ store.update_turn(id, heartbeat_at: Clock.now)
479
593
  end
480
594
 
481
595
  # Claims a pending turn for a standalone media call. Returns the claimed
@@ -486,7 +600,7 @@ module TurnKit
486
600
  def claim_standalone!(action)
487
601
  case status
488
602
  when "pending"
489
- claimed = store.claim_turn(id, from: "pending", to: "running", started_at: Clock.now, heartbeat_at: Clock.now)
603
+ claimed = Background.claim(store, @record)
490
604
  raise Error, "turn is already running" unless claimed
491
605
 
492
606
  claimed
@@ -516,7 +630,21 @@ module TurnKit
516
630
  end
517
631
 
518
632
  def update!(attributes)
519
- @record = store.update_turn(id, attributes)
633
+ terminal = Background::TERMINAL.include?(attributes[:status])
634
+ store.atomic(Background.root_conversation(store, @record)) do
635
+ if terminal
636
+ if attributes[:status] == "failed" && background?
637
+ executions = Reconciliation.interrupt_tool_executions(@record, store: store)
638
+ Reconciliation.repair_transcript(@record, executions, store: store)
639
+ end
640
+ attributes = attributes.merge(claim_token: nil)
641
+ end
642
+ @record = store.update_turn(id, attributes)
643
+ # The terminal write clears this execution's claim token, so durable
644
+ # callback work must use the unfenced store after that write.
645
+ Background.callback_after_terminal(@base_store, @record) if terminal && background?
646
+ Background.wake(conversation.id, store: @base_store) if terminal && background?
647
+ end
520
648
  @started_at = @record["started_at"]
521
649
  @model = @record["model"] || agent.effective_model
522
650
  @record
@@ -535,5 +663,8 @@ module TurnKit
535
663
  @turn = turn
536
664
  @execution = execution
537
665
  end
666
+
667
+ def idempotency_key = "turnkit:tool:#{execution.id}"
668
+ def principal = turn.store.load_turn(turn.id).dig("options", "principal")
538
669
  end
539
670
  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.6.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"
@@ -50,6 +51,10 @@ require_relative "turnkit/run"
50
51
  require_relative "turnkit/adapters/codex"
51
52
  require_relative "turnkit/adapters/ruby_llm"
52
53
  require_relative "turnkit/active_record_store"
54
+ require_relative "turnkit/execution_store"
55
+ require_relative "turnkit/background"
56
+ require_relative "turnkit/coordination_tools"
57
+ require_relative "turnkit/specialists"
53
58
 
54
59
  module TurnKit
55
60
  class << self
@@ -63,6 +68,29 @@ module TurnKit
63
68
  attr_accessor :prompt_sections, :prompt_behavior, :available_skills
64
69
  attr_accessor :prompt_data_max_chars, :context_contributors
65
70
  attr_accessor :on_event
71
+ attr_accessor :job_dispatcher
72
+ attr_accessor :authorization_policy, :maintenance_batch_size
73
+ attr_reader :agents
74
+ end
75
+
76
+ @agents = {}
77
+
78
+ def self.register(agent)
79
+ @agents[agent.name] = agent
80
+ agent.sub_agents.each { |child| register(child) }
81
+ agent
82
+ end
83
+
84
+ def self.resolve_agent(name)
85
+ @agents.fetch(name.to_s) { raise ConfigError, "register agent #{name.inspect} in every worker at boot" }
86
+ end
87
+
88
+ def self.load_turn(id)
89
+ Background.load_turn(id)
90
+ end
91
+
92
+ def self.load_conversation(id)
93
+ Background.load_conversation(id)
66
94
  end
67
95
 
68
96
  self.default_model = "claude-sonnet-4-5"
@@ -84,6 +112,8 @@ module TurnKit
84
112
  self.on_event = nil
85
113
  self.output_policy_model = nil
86
114
  self.output_policy_thinking = { effort: :low }
115
+ self.authorization_policy = nil
116
+ self.maintenance_batch_size = 100
87
117
 
88
118
  def self.configure
89
119
  yield self