turnkit 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +197 -1
- data/UPGRADE.md +57 -0
- data/lib/generators/turnkit/install/templates/create_turnkit_tables.rb +30 -0
- data/lib/generators/turnkit/install/templates/delivery.rb +7 -0
- data/lib/generators/turnkit/install/templates/initializer.rb +5 -0
- data/lib/generators/turnkit/install/templates/wait.rb +7 -0
- data/lib/generators/turnkit/install_generator.rb +2 -0
- data/lib/generators/turnkit/upgrade/templates/add_turnkit_durable_orchestration.rb +36 -0
- data/lib/generators/turnkit/upgrade_generator.rb +34 -0
- data/lib/turnkit/active_record_store.rb +130 -10
- data/lib/turnkit/adapters/ruby_llm.rb +14 -0
- data/lib/turnkit/agent.rb +26 -20
- data/lib/turnkit/authorization.rb +17 -0
- data/lib/turnkit/background.rb +281 -0
- data/lib/turnkit/budget.rb +4 -3
- data/lib/turnkit/conversation.rb +23 -1
- data/lib/turnkit/coordination_tools.rb +61 -0
- data/lib/turnkit/error.rb +3 -0
- data/lib/turnkit/execution_store.rb +30 -0
- data/lib/turnkit/id.rb +1 -0
- data/lib/turnkit/image_tool.rb +10 -0
- data/lib/turnkit/job.rb +21 -0
- data/lib/turnkit/memory_store.rb +110 -14
- data/lib/turnkit/reconciliation.rb +75 -0
- data/lib/turnkit/record.rb +37 -4
- data/lib/turnkit/run.rb +15 -0
- data/lib/turnkit/skill.rb +5 -4
- data/lib/turnkit/specialists.rb +254 -0
- data/lib/turnkit/store.rb +42 -2
- data/lib/turnkit/sub_agent_tool.rb +23 -7
- data/lib/turnkit/system_prompt.rb +7 -7
- data/lib/turnkit/tool.rb +11 -0
- data/lib/turnkit/tool_runner.rb +109 -29
- data/lib/turnkit/turn.rb +188 -57
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +32 -3
- metadata +16 -5
data/lib/turnkit/budget.rb
CHANGED
|
@@ -30,9 +30,9 @@ module TurnKit
|
|
|
30
30
|
def seed!(turns:, tool_executions:)
|
|
31
31
|
@mutex.synchronize do
|
|
32
32
|
@iterations = Array(turns).sum { |turn| Turn.iterations_for(turn) }
|
|
33
|
-
|
|
34
|
-
@tool_executions =
|
|
35
|
-
|
|
33
|
+
reserved = Array(tool_executions).reject { |execution| execution["status"] == "cancelled" || execution.dig("error", "details", "budget_denied") }
|
|
34
|
+
@tool_executions = reserved.length
|
|
35
|
+
reserved.each { |execution| @tool_executions_by_name[execution.fetch("tool_name").to_s] += 1 }
|
|
36
36
|
@cost = Array(turns).sum { |turn| turn["cost"].to_f }
|
|
37
37
|
end
|
|
38
38
|
self
|
|
@@ -74,6 +74,7 @@ module TurnKit
|
|
|
74
74
|
def check!(depth:)
|
|
75
75
|
raise BudgetError, "maximum sub-agent depth reached" if max_depth && depth > max_depth
|
|
76
76
|
raise BudgetError, "turn timed out" if timeout && Clock.now >= root_started_at + timeout
|
|
77
|
+
raise BudgetError, "cost limit reached" if max_spend && @cost > max_spend
|
|
77
78
|
end
|
|
78
79
|
|
|
79
80
|
private
|
data/lib/turnkit/conversation.rb
CHANGED
|
@@ -20,6 +20,24 @@ module TurnKit
|
|
|
20
20
|
append_message(role: "user", kind: "text", text: text, metadata: metadata)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def subject_prompt
|
|
24
|
+
subject.respond_to?(:to_prompt) ? subject.to_prompt.to_s : metadata["turnkit_subject_prompt"].to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def send_message(destination, text, key:, principal: nil)
|
|
28
|
+
Authorization.authorize!(:send_message, principal: principal, source_conversation: id, destination_conversation: destination.respond_to?(:id) ? destination.id : destination)
|
|
29
|
+
Background.send_message(source: id, destination: destination.respond_to?(:id) ? destination.id : destination,
|
|
30
|
+
text: text, key: key, store: store, principal: principal)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def inbox
|
|
34
|
+
store.list_deliveries(destination_conversation_id: id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def outbox
|
|
38
|
+
store.list_deliveries(source_conversation_id: id)
|
|
39
|
+
end
|
|
40
|
+
|
|
23
41
|
def ask(text, async: false, **options)
|
|
24
42
|
trigger = say(text)
|
|
25
43
|
turn = build_turn(trigger_message_id: trigger.id, **options)
|
|
@@ -30,14 +48,18 @@ module TurnKit
|
|
|
30
48
|
build_turn(trigger_message_id: trigger_message_id, model: model, budget: budget, parent_turn: parent_turn, parent_tool_execution: parent_tool_execution, root_turn_id: root_turn_id, depth: depth, agent: agent, thinking: thinking, compact: compact, output_schema: output_schema, prompt_mode: prompt_mode, on_event: on_event).run!
|
|
31
49
|
end
|
|
32
50
|
|
|
33
|
-
def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, prompt_mode: nil, on_event: nil)
|
|
51
|
+
def build_turn(trigger_message_id: nil, model: nil, budget: nil, parent_turn: nil, parent_tool_execution: nil, root_turn_id: nil, depth: 0, agent: self.agent, thinking: THINKING_UNSET, compact: nil, output_schema: nil, prompt_mode: nil, on_event: nil, context: nil, principal: nil)
|
|
34
52
|
snapshot = latest_message_sequence
|
|
35
53
|
effective_thinking = thinking.equal?(THINKING_UNSET) ? agent.effective_thinking : Agent.normalize_thinking(thinking)
|
|
36
54
|
options = { "trigger_message_id" => trigger_message_id }.compact
|
|
55
|
+
options["budget_limits"] = agent.budget_limits.transform_keys(&:to_s)
|
|
37
56
|
options["thinking"] = effective_thinking
|
|
38
57
|
options["compact"] = compact unless compact.nil?
|
|
39
58
|
options["output_schema"] = output_schema || agent.output_schema if output_schema || agent.output_schema
|
|
40
59
|
options["prompt_mode"] = prompt_mode.to_sym if prompt_mode
|
|
60
|
+
options["context"] = JSON.parse(JSON.generate(context || metadata["turnkit_context"] || {}))
|
|
61
|
+
principal ||= metadata["principal"]
|
|
62
|
+
options["principal"] = JSON.parse(JSON.generate(principal)) unless principal.nil?
|
|
41
63
|
record = store.create_turn(
|
|
42
64
|
"conversation_id" => id,
|
|
43
65
|
"agent_name" => agent.name,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
# Opt-in tools: the application chooses which agents can address conversations.
|
|
5
|
+
class SendMessageTool < Tool
|
|
6
|
+
tool_name "send_message"
|
|
7
|
+
description "Send a durable message to a conversation and wake it when idle."
|
|
8
|
+
parameter :conversation_id, :string, required: true
|
|
9
|
+
parameter :text, :string, required: true
|
|
10
|
+
|
|
11
|
+
def call(conversation_id:, text:, context:)
|
|
12
|
+
Background.send_message(source: context.turn.conversation.id, destination: conversation_id,
|
|
13
|
+
text: text, key: "message:#{context.execution.id}", store: context.turn.store, source_turn_id: context.turn.id, principal: context.principal)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class LaunchAgentTool < Tool
|
|
18
|
+
tool_name "launch_agent"
|
|
19
|
+
description "Launch a configured sub-agent independently. Returns IDs immediately; optionally receive a completion message."
|
|
20
|
+
parameter :agent_name, :string, required: true
|
|
21
|
+
parameter :task, :string, required: true
|
|
22
|
+
parameter :callback, :boolean, required: false
|
|
23
|
+
|
|
24
|
+
def call(agent_name:, task:, callback: false, context:)
|
|
25
|
+
parent = context.turn
|
|
26
|
+
agent = parent.agent.sub_agents.find { |candidate| candidate.name == agent_name }
|
|
27
|
+
raise ToolError, "unknown sub-agent: #{agent_name}" unless agent
|
|
28
|
+
Authorization.authorize!(:launch_agent, principal: context.principal, turn: parent, agent: agent, arguments: { "task" => task, "callback" => callback })
|
|
29
|
+
Authorization.authorize!(:callback, principal: context.principal, turn: parent.id, destination_conversation: parent.conversation.id) if callback
|
|
30
|
+
TurnKit.resolve_agent(agent.name)
|
|
31
|
+
child = nil
|
|
32
|
+
parent.store.atomic do
|
|
33
|
+
existing = parent.store.list_turns(root_turn_id: parent.root_turn_id).find { |row| row["parent_tool_execution_id"] == context.execution.id }
|
|
34
|
+
if existing
|
|
35
|
+
child = existing
|
|
36
|
+
else
|
|
37
|
+
built = SubAgentTool.for(agent).build_child(task: task, context: context)
|
|
38
|
+
options = parent.store.load_turn(built.id).fetch("options")
|
|
39
|
+
options = options.merge("callback_conversation_id" => parent.conversation.id) if callback
|
|
40
|
+
child = parent.store.update_turn(built.id, submitted_at: Clock.now, options: options)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
Background.enqueue(child.fetch("id"))
|
|
44
|
+
SubAgentTool.result(child)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class WaitTool < Tool
|
|
49
|
+
tool_name "wait_for"
|
|
50
|
+
description "Suspend this background turn until all listed turns finish. Releases the worker while waiting."
|
|
51
|
+
parameter :turn_ids, :array, required: true
|
|
52
|
+
|
|
53
|
+
def call(turn_ids:, context:)
|
|
54
|
+
raise ToolError, "wait_for requires a background turn" unless context.turn.background?
|
|
55
|
+
ids = Background.wait(context.turn, turn_ids)
|
|
56
|
+
return :waiting unless Background.ready?(context.turn.store, context.turn.id)
|
|
57
|
+
|
|
58
|
+
{ "results" => ids.map { |id| SubAgentTool.result(context.turn.store.load_turn(id)) } }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/turnkit/error.rb
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
class Error < StandardError; end
|
|
5
|
+
class AuthorizationError < Error; end
|
|
5
6
|
class BudgetError < Error; end
|
|
6
7
|
class ConfigError < Error; end
|
|
7
8
|
class CompactionError < Error; end
|
|
8
9
|
class InputError < Error; end
|
|
9
10
|
class ModelAccessError < ConfigError; end
|
|
11
|
+
class ModelError < Error; end
|
|
10
12
|
class StoreError < Error; end
|
|
13
|
+
class LostClaim < Error; end
|
|
11
14
|
class ToolError < Error; end
|
|
12
15
|
class ToolValidationError < ToolError; end
|
|
13
16
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module TurnKit
|
|
6
|
+
# All writes made by an execution, including writes by tools and compaction,
|
|
7
|
+
# are fenced by the same root lock used to revoke its claim.
|
|
8
|
+
class ExecutionStore < SimpleDelegator
|
|
9
|
+
def initialize(store, turn_id:, token:, conversation_id:)
|
|
10
|
+
super(store)
|
|
11
|
+
@turn_id, @token, @conversation_id = turn_id, token, conversation_id
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def atomic(_conversation_id = nil)
|
|
15
|
+
__getobj__.atomic(@conversation_id) do
|
|
16
|
+
raise LostClaim, "turn ownership was revoked" unless load_turn(@turn_id)["claim_token"] == @token
|
|
17
|
+
|
|
18
|
+
yield
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
%i[create_conversation append_message next_message_sequence create_turn update_turn
|
|
23
|
+
claim_turn create_tool_execution claim_tool_execution create_delivery update_delivery
|
|
24
|
+
create_wait].each do |method|
|
|
25
|
+
define_method(method) do |*args, **kwargs|
|
|
26
|
+
atomic { __getobj__.public_send(method, *args, **kwargs) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/turnkit/id.rb
CHANGED
data/lib/turnkit/image_tool.rb
CHANGED
|
@@ -18,11 +18,21 @@ module TurnKit
|
|
|
18
18
|
provider: self.class.provider,
|
|
19
19
|
size: self.class.size,
|
|
20
20
|
assume_model_exists: self.class.assume_model_exists,
|
|
21
|
+
input_images: input_images(**arguments),
|
|
22
|
+
mask: mask(**arguments),
|
|
21
23
|
params: self.class.params || {},
|
|
22
24
|
metadata: metadata(**arguments)
|
|
23
25
|
).to_h
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
def input_images(**)
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def mask(**)
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
def metadata(**)
|
|
27
37
|
{}
|
|
28
38
|
end
|
data/lib/turnkit/job.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
gem "activejob", ">= 7.2"
|
|
4
|
+
gem "activerecord", ">= 7.2"
|
|
5
|
+
require "active_job"
|
|
6
|
+
require "active_record"
|
|
7
|
+
|
|
8
|
+
module TurnKit
|
|
9
|
+
class Job < ActiveJob::Base
|
|
10
|
+
def perform(turn_id = nil)
|
|
11
|
+
Background.perform(turn_id)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Schedule with the application's existing recurring-job facility.
|
|
16
|
+
class ReconcileJob < ActiveJob::Base
|
|
17
|
+
def perform
|
|
18
|
+
Background.reconcile
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/turnkit/memory_store.rb
CHANGED
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
3
5
|
module TurnKit
|
|
4
6
|
class MemoryStore < Store
|
|
5
7
|
def initialize
|
|
6
|
-
@mutex =
|
|
8
|
+
@mutex = Monitor.new
|
|
7
9
|
@conversations = {}
|
|
8
10
|
@turns = {}
|
|
9
11
|
@messages = {}
|
|
10
12
|
@tool_executions = {}
|
|
13
|
+
@deliveries = {}
|
|
14
|
+
@delivery_keys = {}
|
|
15
|
+
@waits = {}
|
|
11
16
|
@message_sequences = Hash.new(0)
|
|
17
|
+
@transaction_depth = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def atomic(_conversation_id)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
snapshot = Marshal.dump([@conversations, @turns, @messages, @tool_executions, @deliveries, @delivery_keys, @waits, @message_sequences]) if @transaction_depth.zero?
|
|
23
|
+
@transaction_depth += 1
|
|
24
|
+
committed = false
|
|
25
|
+
begin
|
|
26
|
+
result = yield
|
|
27
|
+
committed = true
|
|
28
|
+
result
|
|
29
|
+
ensure
|
|
30
|
+
@transaction_depth -= 1
|
|
31
|
+
if snapshot && !committed
|
|
32
|
+
@conversations, @turns, @messages, @tool_executions, @deliveries, @delivery_keys, @waits, @message_sequences = Marshal.load(snapshot)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
12
36
|
end
|
|
37
|
+
def atomic_graph(&block) = atomic(nil, &block)
|
|
13
38
|
|
|
14
39
|
def create_conversation(attributes)
|
|
15
40
|
record = Record.conversation(attributes)
|
|
@@ -89,6 +114,86 @@ module TurnKit
|
|
|
89
114
|
end
|
|
90
115
|
end
|
|
91
116
|
|
|
117
|
+
def list_submitted_turns(limit: nil)
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
rows = @turns.values.select { |turn| turn["submitted_at"] }.sort_by { |turn| [ turn["created_at"].to_f, turn["id"] ] }
|
|
120
|
+
rows = rows.first(limit) if limit
|
|
121
|
+
rows.map { |turn| duplicate(turn) }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def list_actionable_turns(limit:)
|
|
126
|
+
@mutex.synchronize do
|
|
127
|
+
@turns.values.select { |turn| turn["submitted_at"] && %w[pending waiting running].include?(turn["status"]) }
|
|
128
|
+
.sort_by { |turn| [ turn["updated_at"].to_f, turn["id"] ] }.first(limit).map { |turn| duplicate(turn) }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def list_stale_inline_turns(before:, limit:)
|
|
133
|
+
@mutex.synchronize do
|
|
134
|
+
@turns.values.select { |row| !row["submitted_at"] && %w[pending running].include?(row["status"]) &&
|
|
135
|
+
(row["heartbeat_at"] || row["started_at"] || row["created_at"]) < before }
|
|
136
|
+
.sort_by { |row| [row["updated_at"].to_f, row["id"]] }.first(limit).map { |row| duplicate(row) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def create_delivery(attributes)
|
|
141
|
+
record = Record.delivery(attributes)
|
|
142
|
+
@mutex.synchronize do
|
|
143
|
+
existing_id = @delivery_keys[record.fetch("key")]
|
|
144
|
+
if existing_id
|
|
145
|
+
existing = @deliveries.fetch(existing_id)
|
|
146
|
+
Record.assert_delivery_retry!(existing, record)
|
|
147
|
+
return duplicate(existing)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
@deliveries[record.fetch("id")] = record
|
|
151
|
+
@delivery_keys[record.fetch("key")] = record.fetch("id")
|
|
152
|
+
duplicate(record)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def load_delivery(id)
|
|
157
|
+
@mutex.synchronize { duplicate(@deliveries.fetch(id)) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def update_delivery(id, attributes)
|
|
161
|
+
attrs = Record.delivery_update(attributes)
|
|
162
|
+
@mutex.synchronize do
|
|
163
|
+
@deliveries.fetch(id).merge!(attrs)
|
|
164
|
+
duplicate(@deliveries.fetch(id))
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def list_deliveries(source_conversation_id: nil, destination_conversation_id: nil, pending: false, limit: nil)
|
|
169
|
+
@mutex.synchronize do
|
|
170
|
+
rows = @deliveries.values
|
|
171
|
+
rows = rows.select { |row| row["source_conversation_id"] == source_conversation_id } if source_conversation_id
|
|
172
|
+
rows = rows.select { |row| row["destination_conversation_id"] == destination_conversation_id } if destination_conversation_id
|
|
173
|
+
rows = rows.select { |row| row["delivered_at"].nil? } if pending
|
|
174
|
+
rows = rows.sort_by { |row| [ row["created_at"].to_f, row["id"] ] }
|
|
175
|
+
rows = rows.first(limit) if limit
|
|
176
|
+
rows.map { |row| duplicate(row) }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def create_wait(turn_id:, target_turn_id:)
|
|
181
|
+
@mutex.synchronize do
|
|
182
|
+
wait = { "turn_id" => turn_id, "target_turn_id" => target_turn_id }
|
|
183
|
+
@waits[[ turn_id, target_turn_id ]] ||= wait
|
|
184
|
+
duplicate(@waits.fetch([ turn_id, target_turn_id ]))
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def list_waits(turn_id: nil, target_turn_id: nil)
|
|
189
|
+
@mutex.synchronize do
|
|
190
|
+
rows = @waits.values
|
|
191
|
+
rows = rows.select { |row| row["turn_id"] == turn_id } if turn_id
|
|
192
|
+
rows = rows.select { |row| row["target_turn_id"] == target_turn_id } if target_turn_id
|
|
193
|
+
rows.map { |row| duplicate(row) }
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
92
197
|
def create_tool_execution(attributes)
|
|
93
198
|
record = Record.tool_execution(attributes)
|
|
94
199
|
|
|
@@ -100,10 +205,12 @@ module TurnKit
|
|
|
100
205
|
@mutex.synchronize { duplicate(@tool_executions.fetch(id)) }
|
|
101
206
|
end
|
|
102
207
|
|
|
103
|
-
def
|
|
104
|
-
attrs = Record.tool_execution_update(attributes)
|
|
208
|
+
def claim_tool_execution(id, from: "running", to: "completed", **attributes)
|
|
209
|
+
attrs = Record.tool_execution_update(attributes.merge(status: to))
|
|
105
210
|
@mutex.synchronize do
|
|
106
211
|
record = @tool_executions.fetch(id)
|
|
212
|
+
return nil unless record["status"] == from
|
|
213
|
+
|
|
107
214
|
record.merge!(attrs.merge("updated_at" => Clock.now))
|
|
108
215
|
duplicate(record)
|
|
109
216
|
end
|
|
@@ -118,14 +225,6 @@ module TurnKit
|
|
|
118
225
|
end
|
|
119
226
|
end
|
|
120
227
|
|
|
121
|
-
def find_stale_turns(before:)
|
|
122
|
-
@mutex.synchronize do
|
|
123
|
-
@turns.values.select do |turn|
|
|
124
|
-
%w[pending running].include?(turn["status"]) && stale_anchor(turn) && stale_anchor(turn) < before
|
|
125
|
-
end.map { |turn| duplicate(turn) }
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
228
|
private
|
|
130
229
|
def stringify(hash)
|
|
131
230
|
hash.transform_keys(&:to_s)
|
|
@@ -135,8 +234,5 @@ module TurnKit
|
|
|
135
234
|
Marshal.load(Marshal.dump(value))
|
|
136
235
|
end
|
|
137
236
|
|
|
138
|
-
def stale_anchor(turn)
|
|
139
|
-
turn["heartbeat_at"] || turn["started_at"] || turn["created_at"]
|
|
140
|
-
end
|
|
141
237
|
end
|
|
142
238
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
# Reconciles turns abandoned by a dead worker: atomically marks them stale,
|
|
5
|
+
# marks their unfinished tool executions interrupted, and appends synthetic
|
|
6
|
+
# error tool results so the persisted transcript stays structurally complete
|
|
7
|
+
# for continuation. Unknown effects are not replayed; Background separately
|
|
8
|
+
# retries tools whose integrations explicitly declare replay safety.
|
|
9
|
+
module Reconciliation
|
|
10
|
+
INTERRUPTED_MESSAGE = "Tool execution was interrupted before a result was recorded. " \
|
|
11
|
+
"It is unknown whether the operation ran; do not assume it did or did not."
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def reconcile!(before:)
|
|
16
|
+
reconciled = TurnKit.store.reconcile_stale_turns(before: before)
|
|
17
|
+
reconciled.each do |turn|
|
|
18
|
+
emit("turn.stale", turn)
|
|
19
|
+
executions = interrupt_tool_executions(turn)
|
|
20
|
+
repair_transcript(turn, executions)
|
|
21
|
+
end
|
|
22
|
+
Background.reconcile(before: before) if TurnKit.store.list_actionable_turns(limit: 1).any?
|
|
23
|
+
reconciled
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def interrupt_tool_executions(turn, store: TurnKit.store)
|
|
27
|
+
store.list_tool_executions(turn_id: turn.fetch("id")).map do |execution|
|
|
28
|
+
next execution unless %w[pending running].include?(execution.fetch("status"))
|
|
29
|
+
|
|
30
|
+
interrupted = store.claim_tool_execution(
|
|
31
|
+
execution.fetch("id"),
|
|
32
|
+
from: execution.fetch("status"),
|
|
33
|
+
to: "interrupted",
|
|
34
|
+
error: { "message" => "interrupted: worker terminated while the tool was executing" },
|
|
35
|
+
completed_at: Clock.now
|
|
36
|
+
)
|
|
37
|
+
next execution unless interrupted
|
|
38
|
+
|
|
39
|
+
emit("tool_call.interrupted", turn, id: interrupted.fetch("tool_call_id"), name: interrupted.fetch("tool_name"), tool_execution_id: interrupted.fetch("id"))
|
|
40
|
+
interrupted
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def repair_transcript(turn, executions, store: TurnKit.store)
|
|
45
|
+
messages = store.list_messages(turn.fetch("conversation_id"))
|
|
46
|
+
resolved = messages
|
|
47
|
+
.select { |message| message["turn_id"] == turn.fetch("id") && message["kind"] == "tool_result" }
|
|
48
|
+
.flat_map { |message| message["content"].map { |part| part["tool_call_id"] } }
|
|
49
|
+
|
|
50
|
+
messages
|
|
51
|
+
.select { |message| message["turn_id"] == turn.fetch("id") && message["kind"] == "tool_call" }
|
|
52
|
+
.flat_map { |message| message["content"].select { |part| part["type"] == "tool_call" } }
|
|
53
|
+
.reject { |part| resolved.include?(part["id"]) }
|
|
54
|
+
.each do |part|
|
|
55
|
+
execution = executions.find { |candidate| candidate["tool_call_id"] == part["id"] }
|
|
56
|
+
known = execution && %w[completed failed cancelled].include?(execution["status"])
|
|
57
|
+
payload = known ? execution["result"] || execution["error"] : { "error" => true, "message" => INTERRUPTED_MESSAGE }
|
|
58
|
+
message = store.append_message(
|
|
59
|
+
"conversation_id" => turn.fetch("conversation_id"),
|
|
60
|
+
"turn_id" => turn.fetch("id"),
|
|
61
|
+
"role" => "tool",
|
|
62
|
+
"kind" => "tool_result",
|
|
63
|
+
"content" => [ { "type" => "tool_result", "tool_call_id" => part["id"], "text" => payload.to_json, "error" => !known || execution["status"] == "failed" } ],
|
|
64
|
+
"tool_execution_id" => execution&.fetch("id"),
|
|
65
|
+
"metadata" => { "tool_name" => part["name"], "interrupted" => !known }
|
|
66
|
+
)
|
|
67
|
+
emit("message.created", turn, message_id: message.fetch("id"), role: "tool", kind: "tool_result")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def emit(type, turn, payload = {})
|
|
72
|
+
TurnKit.on_event&.call(Event.new(type: type, turn_id: turn.fetch("id"), conversation_id: turn.fetch("conversation_id"), payload: payload))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/turnkit/record.rb
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module TurnKit
|
|
4
4
|
module Record
|
|
5
|
-
TURN_STATUSES = %w[pending running completed failed cancelled stale].freeze
|
|
6
|
-
TOOL_EXECUTION_STATUSES = %w[pending running completed failed cancelled].freeze
|
|
5
|
+
TURN_STATUSES = %w[pending waiting running completed failed cancelled stale].freeze
|
|
6
|
+
TOOL_EXECUTION_STATUSES = %w[pending running completed failed cancelled interrupted].freeze
|
|
7
7
|
|
|
8
|
-
TURN_UPDATE_KEYS = %w[status options usage cost error output_text output_data started_at heartbeat_at completed_at].freeze
|
|
8
|
+
TURN_UPDATE_KEYS = %w[status options usage cost error output_text output_data submitted_at claim_token started_at heartbeat_at completed_at].freeze
|
|
9
|
+
DELIVERY_UPDATE_KEYS = %w[message_id delivered_at].freeze
|
|
9
10
|
TOOL_EXECUTION_UPDATE_KEYS = %w[status result error started_at completed_at].freeze
|
|
10
11
|
|
|
11
12
|
module_function
|
|
@@ -13,11 +14,12 @@ module TurnKit
|
|
|
13
14
|
def conversation(attributes)
|
|
14
15
|
attrs = stringify(attributes)
|
|
15
16
|
now = Clock.now
|
|
17
|
+
subject_type, subject_id = subject_pair(attrs["subject"])
|
|
16
18
|
{
|
|
17
19
|
"id" => attrs["id"] || Id.generate(:conversation),
|
|
18
20
|
"agent_name" => attrs["agent_name"],
|
|
19
21
|
"model" => attrs["model"],
|
|
20
|
-
"subject" => attrs["subject"],
|
|
22
|
+
"subject" => attrs["subject"] && { "type" => subject_type, "id" => subject_id }.compact,
|
|
21
23
|
"metadata" => attrs["metadata"] || {},
|
|
22
24
|
"created_at" => attrs["created_at"] || now,
|
|
23
25
|
"updated_at" => attrs["updated_at"] || now
|
|
@@ -50,6 +52,8 @@ module TurnKit
|
|
|
50
52
|
"error" => attrs["error"],
|
|
51
53
|
"output_text" => attrs["output_text"],
|
|
52
54
|
"output_data" => attrs["output_data"],
|
|
55
|
+
"submitted_at" => attrs["submitted_at"],
|
|
56
|
+
"claim_token" => attrs["claim_token"],
|
|
53
57
|
"started_at" => attrs["started_at"],
|
|
54
58
|
"heartbeat_at" => attrs["heartbeat_at"],
|
|
55
59
|
"completed_at" => attrs["completed_at"],
|
|
@@ -58,6 +62,35 @@ module TurnKit
|
|
|
58
62
|
}
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
def delivery(attributes)
|
|
66
|
+
attrs = stringify(attributes)
|
|
67
|
+
{
|
|
68
|
+
"id" => attrs["id"] || Id.generate(:delivery),
|
|
69
|
+
"source_conversation_id" => attrs.fetch("source_conversation_id"),
|
|
70
|
+
"destination_conversation_id" => attrs.fetch("destination_conversation_id"),
|
|
71
|
+
"source_turn_id" => attrs["source_turn_id"],
|
|
72
|
+
"key" => attrs.fetch("key"),
|
|
73
|
+
"payload" => JSON.parse(JSON.generate(attrs["payload"] || {})),
|
|
74
|
+
"message_id" => attrs["message_id"],
|
|
75
|
+
"delivered_at" => attrs["delivered_at"],
|
|
76
|
+
"created_at" => attrs["created_at"] || Clock.now
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def assert_delivery_retry!(existing, requested)
|
|
81
|
+
keys = %w[source_conversation_id destination_conversation_id source_turn_id payload]
|
|
82
|
+
unless existing.slice(*keys) == requested.slice(*keys)
|
|
83
|
+
raise ToolError, "delivery key is already used for a different message"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def delivery_update(attributes)
|
|
88
|
+
attrs = stringify(attributes)
|
|
89
|
+
unknown = attrs.keys - DELIVERY_UPDATE_KEYS
|
|
90
|
+
raise ArgumentError, "unknown delivery update attributes: #{unknown.join(", ")}" if unknown.any?
|
|
91
|
+
attrs
|
|
92
|
+
end
|
|
93
|
+
|
|
61
94
|
def tool_execution(attributes)
|
|
62
95
|
attrs = stringify(attributes)
|
|
63
96
|
status = attrs["status"] || "pending"
|
data/lib/turnkit/run.rb
CHANGED
|
@@ -37,6 +37,21 @@ module TurnKit
|
|
|
37
37
|
self
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def perform_later(callback: nil)
|
|
41
|
+
turn.perform_later(callback: callback)
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def wait_for(*targets)
|
|
46
|
+
turn.wait_for(*targets)
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cancel!(descendants: :retain, principal: nil)
|
|
51
|
+
turn.cancel!(descendants: descendants, principal: principal)
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
40
55
|
def reload
|
|
41
56
|
turn.reload
|
|
42
57
|
self
|
data/lib/turnkit/skill.rb
CHANGED
|
@@ -4,23 +4,24 @@ require "yaml"
|
|
|
4
4
|
|
|
5
5
|
module TurnKit
|
|
6
6
|
class Skill
|
|
7
|
-
attr_reader :key, :name, :description, :content
|
|
7
|
+
attr_reader :key, :name, :description, :content, :tools
|
|
8
8
|
|
|
9
|
-
def self.from_file(path, key: nil, name: nil, description: "")
|
|
9
|
+
def self.from_file(path, key: nil, name: nil, description: "", tools: [])
|
|
10
10
|
content, metadata = parse_file(File.read(path))
|
|
11
11
|
base = File.basename(path, File.extname(path))
|
|
12
|
-
new(key: key || base, name: name || metadata["name"] || base.tr("_-", " ").split.map(&:capitalize).join(" "), description: description.to_s.empty? ? metadata["description"].to_s : description, content: content)
|
|
12
|
+
new(key: key || base, name: name || metadata["name"] || base.tr("_-", " ").split.map(&:capitalize).join(" "), description: description.to_s.empty? ? metadata["description"].to_s : description, content: content, tools: tools)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def self.from_directory(path, pattern: "*.md")
|
|
16
16
|
Dir.glob(File.join(path, pattern)).sort.map { |file| from_file(file) }
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def initialize(key:, name:, content:, description: "")
|
|
19
|
+
def initialize(key:, name:, content:, description: "", tools: [])
|
|
20
20
|
@key = key.to_s
|
|
21
21
|
@name = name.to_s
|
|
22
22
|
@description = description.to_s
|
|
23
23
|
@content = content.to_s
|
|
24
|
+
@tools = Array(tools).dup.freeze
|
|
24
25
|
raise ArgumentError, "key is required" if @key.empty?
|
|
25
26
|
raise ArgumentError, "name is required" if @name.empty?
|
|
26
27
|
raise ArgumentError, "content is required" if @content.empty?
|