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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +198 -5
- data/UPGRADE.md +57 -0
- data/lib/generators/turnkit/install/templates/create_turnkit_tables.rb +30 -0
- data/lib/generators/turnkit/install/templates/delivery.rb +7 -0
- data/lib/generators/turnkit/install/templates/initializer.rb +5 -0
- data/lib/generators/turnkit/install/templates/wait.rb +7 -0
- data/lib/generators/turnkit/install_generator.rb +2 -0
- data/lib/generators/turnkit/upgrade/templates/add_turnkit_durable_orchestration.rb +36 -0
- data/lib/generators/turnkit/upgrade_generator.rb +34 -0
- data/lib/turnkit/active_record_store.rb +124 -14
- data/lib/turnkit/adapters/ruby_llm.rb +107 -21
- data/lib/turnkit/agent.rb +26 -20
- data/lib/turnkit/authorization.rb +17 -0
- data/lib/turnkit/background.rb +281 -0
- data/lib/turnkit/budget.rb +4 -3
- data/lib/turnkit/client.rb +3 -1
- data/lib/turnkit/conversation.rb +54 -1
- data/lib/turnkit/coordination_tools.rb +67 -0
- data/lib/turnkit/cost.rb +7 -7
- data/lib/turnkit/error.rb +3 -0
- data/lib/turnkit/execution_store.rb +30 -0
- data/lib/turnkit/id.rb +1 -0
- data/lib/turnkit/image_tool.rb +10 -0
- data/lib/turnkit/job.rb +21 -0
- data/lib/turnkit/memory_store.rb +113 -20
- data/lib/turnkit/message_projection.rb +4 -1
- data/lib/turnkit/reconciliation.rb +13 -10
- data/lib/turnkit/record.rb +36 -3
- data/lib/turnkit/run.rb +28 -0
- data/lib/turnkit/skill.rb +5 -4
- data/lib/turnkit/specialists.rb +254 -0
- data/lib/turnkit/store.rb +38 -9
- data/lib/turnkit/sub_agent_tool.rb +23 -7
- data/lib/turnkit/system_prompt.rb +7 -7
- data/lib/turnkit/tool.rb +11 -0
- data/lib/turnkit/tool_runner.rb +109 -45
- data/lib/turnkit/turn.rb +238 -61
- data/lib/turnkit/turn_controls.rb +136 -0
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +31 -0
- 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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
396
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
data/lib/turnkit/version.rb
CHANGED
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
|