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
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TurnKit
|
|
4
|
+
# Jobs are wake-up signals. Submitted turns, deliveries, and waits in the
|
|
5
|
+
# store are authoritative and can be rediscovered after a lost enqueue.
|
|
6
|
+
module Background
|
|
7
|
+
TERMINAL = %w[completed failed cancelled].freeze
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def root_conversation(store, record)
|
|
11
|
+
store.load_turn(record.fetch("root_turn_id")).fetch("conversation_id")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def load_turn(id, store: TurnKit.store)
|
|
15
|
+
record = store.load_turn(id)
|
|
16
|
+
agent = TurnKit.resolve_agent(record.fetch("agent_name"))
|
|
17
|
+
conversation = load_conversation(record.fetch("conversation_id"), store: store, agent: agent)
|
|
18
|
+
depth = 0
|
|
19
|
+
ancestor = record
|
|
20
|
+
while ancestor["parent_turn_id"]
|
|
21
|
+
depth += 1
|
|
22
|
+
ancestor = store.load_turn(ancestor.fetch("parent_turn_id"))
|
|
23
|
+
end
|
|
24
|
+
Turn.new(agent: agent, conversation: conversation, record: record, store: store, depth: depth)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def load_conversation(id, store: TurnKit.store, agent: nil)
|
|
28
|
+
record = store.load_conversation(id)
|
|
29
|
+
agent ||= TurnKit.resolve_agent(record.fetch("agent_name"))
|
|
30
|
+
Conversation.new(agent: agent, record: record, store: store, model: record["model"], subject: record["subject"], metadata: record["metadata"])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def submit(turn, callback: nil)
|
|
34
|
+
store = turn.store
|
|
35
|
+
raw = store
|
|
36
|
+
raw = raw.__getobj__ while raw.is_a?(ExecutionStore)
|
|
37
|
+
raise ConfigError, "background turns must use TurnKit.store" unless raw.equal?(TurnKit.store)
|
|
38
|
+
TurnKit.resolve_agent(turn.agent_name)
|
|
39
|
+
principal = store.load_turn(turn.id).dig("options", "principal")
|
|
40
|
+
Authorization.authorize!(:callback, principal: principal, turn: turn.id, destination_conversation: callback) if callback
|
|
41
|
+
store.load_conversation(callback) if callback
|
|
42
|
+
store.atomic(root_conversation(store, store.load_turn(turn.id))) do
|
|
43
|
+
record = store.load_turn(turn.id)
|
|
44
|
+
raise Error, "only pending or paused turns can be submitted" unless %w[pending paused].include?(record["status"])
|
|
45
|
+
options = record.fetch("options")
|
|
46
|
+
options = options.merge("callback_conversation_id" => callback) if callback
|
|
47
|
+
store.update_turn(turn.id, submitted_at: record["submitted_at"] || Clock.now, options: options,
|
|
48
|
+
status: record["status"] == "paused" ? "paused" : ready?(store, turn.id) ? "pending" : "waiting")
|
|
49
|
+
end
|
|
50
|
+
enqueue(turn.id)
|
|
51
|
+
turn.reload
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def enqueue(id = nil)
|
|
55
|
+
if TurnKit.store.is_a?(ActiveRecordStore)
|
|
56
|
+
require_relative "job"
|
|
57
|
+
ActiveRecord.after_all_transactions_commit { dispatch_job(id) }
|
|
58
|
+
else
|
|
59
|
+
dispatch_job(id)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def dispatch_job(id)
|
|
64
|
+
if TurnKit.job_dispatcher
|
|
65
|
+
TurnKit.job_dispatcher.call(id)
|
|
66
|
+
else
|
|
67
|
+
raise ConfigError, "background jobs require ActiveRecordStore" unless TurnKit.store.is_a?(ActiveRecordStore)
|
|
68
|
+
require_relative "job"
|
|
69
|
+
if [ActiveJob::QueueAdapters::InlineAdapter, ActiveJob::QueueAdapters::AsyncAdapter].any? { |type| Job.queue_adapter.is_a?(type) }
|
|
70
|
+
raise ConfigError, "configure a persistent Active Job backend (not inline or async)"
|
|
71
|
+
end
|
|
72
|
+
Job.perform_later(id)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def perform(id = nil)
|
|
77
|
+
if id
|
|
78
|
+
turn = load_turn(id)
|
|
79
|
+
turn.run! if turn.background?
|
|
80
|
+
end
|
|
81
|
+
drain
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def claim(store, record)
|
|
85
|
+
store.atomic(root_conversation(store, record)) do
|
|
86
|
+
store.atomic(record.fetch("conversation_id")) do
|
|
87
|
+
current = store.load_turn(record.fetch("id"))
|
|
88
|
+
next unless current["status"] == "pending"
|
|
89
|
+
state = current.dig("options", "state") || {}
|
|
90
|
+
next if !state["phase"] && !ready?(store, current.fetch("id")) && !deadline_exceeded?(store, current)
|
|
91
|
+
if current["submitted_at"]
|
|
92
|
+
others = store.list_turns(conversation_id: current.fetch("conversation_id")).reject { |row| row["id"] == current["id"] }
|
|
93
|
+
next if others.any? { |row| %w[running waiting paused].include?(row["status"]) }
|
|
94
|
+
first = ([current] + others.select { |row| row["submitted_at"] && row["status"] == "pending" }).min_by { |row| [row["created_at"], row["id"]] }
|
|
95
|
+
next unless first["id"] == current["id"]
|
|
96
|
+
end
|
|
97
|
+
# An application-level join supplies its results as turn-local input.
|
|
98
|
+
# Tool-level joins already have a persisted tools phase/result channel.
|
|
99
|
+
waits = store.list_waits(turn_id: current.fetch("id"))
|
|
100
|
+
if waits.any? && !state["phase"] && !state["wait_input"] && ready?(store, current.fetch("id"))
|
|
101
|
+
results = waits.map { |wait| SubAgentTool.result(store.load_turn(wait.fetch("target_turn_id"))) }
|
|
102
|
+
store.append_message("conversation_id" => current.fetch("conversation_id"), "turn_id" => current.fetch("id"),
|
|
103
|
+
"role" => "user", "kind" => "text", "text" => { "wait_results" => results }.to_json)
|
|
104
|
+
current = store.update_turn(current.fetch("id"), options: current.fetch("options").merge("state" => state.merge("wait_input" => true)))
|
|
105
|
+
end
|
|
106
|
+
store.claim_turn(current.fetch("id"), started_at: current["started_at"] || Clock.now, heartbeat_at: Clock.now, claim_token: SecureRandom.hex(16))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def send_message(source:, destination:, text:, key:, store: TurnKit.store, source_turn_id: nil, principal: nil)
|
|
112
|
+
Authorization.authorize!(:send_message, principal: principal, source_conversation: source, destination_conversation: destination)
|
|
113
|
+
store.load_conversation(destination)
|
|
114
|
+
delivery = store.create_delivery(
|
|
115
|
+
"source_conversation_id" => source, "destination_conversation_id" => destination,
|
|
116
|
+
"source_turn_id" => source_turn_id, "key" => key, "payload" => { "text" => text }
|
|
117
|
+
)
|
|
118
|
+
enqueue
|
|
119
|
+
delivery
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def callback(store, record)
|
|
123
|
+
destination = record.dig("options", "callback_conversation_id")
|
|
124
|
+
return unless destination && TERMINAL.include?(record["status"])
|
|
125
|
+
|
|
126
|
+
Authorization.authorize!(:callback, principal: record.dig("options", "principal"), turn: record.fetch("id"), destination_conversation: destination)
|
|
127
|
+
|
|
128
|
+
store.create_delivery(
|
|
129
|
+
"source_conversation_id" => record.fetch("conversation_id"), "destination_conversation_id" => destination,
|
|
130
|
+
"source_turn_id" => record.fetch("id"), "key" => "completion:#{record.fetch('id')}",
|
|
131
|
+
"payload" => { "text" => SubAgentTool.result(record).to_json }
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def callback_after_terminal(store, record)
|
|
136
|
+
callback(store, record)
|
|
137
|
+
rescue AuthorizationError => error
|
|
138
|
+
options = record.fetch("options").merge("callback_denied" => {
|
|
139
|
+
"class" => error.class.name, "message" => error.message, "at" => Clock.now.iso8601
|
|
140
|
+
})
|
|
141
|
+
store.update_turn(record.fetch("id"), options: options)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def deliver(delivery, store: TurnKit.store)
|
|
145
|
+
destination = delivery.fetch("destination_conversation_id")
|
|
146
|
+
store.atomic(destination) do
|
|
147
|
+
delivery = store.load_delivery(delivery.fetch("id"))
|
|
148
|
+
next if delivery["delivered_at"]
|
|
149
|
+
|
|
150
|
+
message = store.append_message(
|
|
151
|
+
"conversation_id" => destination, "role" => "user", "kind" => "text",
|
|
152
|
+
"text" => delivery.fetch("payload").fetch("text"),
|
|
153
|
+
"metadata" => { "delivery_id" => delivery.fetch("id"), "principal" => delivery.dig("payload", "principal"), "source_conversation_id" => delivery["source_conversation_id"], "source_turn_id" => delivery["source_turn_id"] }
|
|
154
|
+
)
|
|
155
|
+
store.update_delivery(delivery.fetch("id"), message_id: message.fetch("id"), delivered_at: Clock.now)
|
|
156
|
+
wake(destination, store: store)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def wake(conversation_id, store: TurnKit.store)
|
|
161
|
+
store.atomic(conversation_id) do
|
|
162
|
+
next if store.busy_conversation?(conversation_id)
|
|
163
|
+
incoming = store.next_delivery_trigger(conversation_id)
|
|
164
|
+
next unless incoming
|
|
165
|
+
|
|
166
|
+
conversation = load_conversation(conversation_id, store: store)
|
|
167
|
+
turn = conversation.build_turn(trigger_message_id: incoming.fetch("id"), principal: conversation.metadata["principal"])
|
|
168
|
+
store.update_turn(turn.id, submitted_at: Clock.now)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def wait(turn, targets, transition: false)
|
|
173
|
+
ids = Array(targets).map { |target| target.respond_to?(:id) ? target.id : target.to_s }.uniq
|
|
174
|
+
principal = turn.store.load_turn(turn.id).dig("options", "principal")
|
|
175
|
+
Authorization.authorize!(:wait, principal: principal, turn: turn.id, targets: ids)
|
|
176
|
+
turn.store.atomic_graph do
|
|
177
|
+
turn.store.atomic(root_conversation(turn.store, turn.store.load_turn(turn.id))) do
|
|
178
|
+
current = turn.store.load_turn(turn.id)
|
|
179
|
+
raise Error, "only pending turns can wait" if transition && current["status"] != "pending"
|
|
180
|
+
ids.each do |id|
|
|
181
|
+
raise ToolError, "a turn cannot wait for itself" if id == turn.id
|
|
182
|
+
target = turn.store.load_turn(id)
|
|
183
|
+
raise ToolError, "wait targets must be submitted" unless target["submitted_at"] || TERMINAL.include?(target["status"])
|
|
184
|
+
if target["conversation_id"] == turn.conversation.id && !TERMINAL.include?(target["status"])
|
|
185
|
+
raise ToolError, "cannot wait for unfinished work in the same conversation"
|
|
186
|
+
end
|
|
187
|
+
raise ToolError, "wait would create a cycle" if wait_reachable?(turn.store, id, turn.id)
|
|
188
|
+
turn.store.create_wait(turn_id: turn.id, target_turn_id: id)
|
|
189
|
+
end
|
|
190
|
+
turn.store.update_turn(turn.id, status: "waiting") if transition && current["submitted_at"] && !ready?(turn.store, turn.id)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
ids
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def wait_reachable?(store, from, sought, seen = {})
|
|
197
|
+
target = store.load_turn(from)
|
|
198
|
+
return false if TERMINAL.include?(target["status"])
|
|
199
|
+
conversation = target.fetch("conversation_id")
|
|
200
|
+
return true if conversation == store.load_turn(sought).fetch("conversation_id")
|
|
201
|
+
return false if seen[conversation]
|
|
202
|
+
seen[conversation] = true
|
|
203
|
+
# A conversation is a serial execution lane. Conservatively include waits
|
|
204
|
+
# of every unfinished peer, not just explicit edges on the requested turn.
|
|
205
|
+
store.list_turns(conversation_id: conversation).reject { |row| TERMINAL.include?(row["status"]) }.any? do |row|
|
|
206
|
+
store.list_waits(turn_id: row.fetch("id")).any? { |edge| wait_reachable?(store, edge.fetch("target_turn_id"), sought, seen) }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def ready?(store, id)
|
|
211
|
+
store.list_waits(turn_id: id).all? { |wait| TERMINAL.include?(store.load_turn(wait.fetch("target_turn_id"))["status"]) }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def deadline_exceeded?(store, record)
|
|
215
|
+
root = store.load_turn(record.fetch("root_turn_id"))
|
|
216
|
+
limits = root.dig("options", "budget_limits") || {}
|
|
217
|
+
timeout = limits.fetch("timeout", TurnKit.timeout)
|
|
218
|
+
start = root["submitted_at"] || root["started_at"]
|
|
219
|
+
timeout && start && Clock.now >= start + timeout
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def drain(store: TurnKit.store)
|
|
223
|
+
limit = TurnKit.maintenance_batch_size
|
|
224
|
+
store.list_deliveries(pending: true, limit: limit).each { |delivery| deliver(delivery, store: store) }
|
|
225
|
+
turns = store.list_actionable_turns(limit: limit)
|
|
226
|
+
turns.each do |record|
|
|
227
|
+
if record["status"] == "waiting"
|
|
228
|
+
store.atomic(root_conversation(store, record)) do
|
|
229
|
+
current = store.load_turn(record.fetch("id"))
|
|
230
|
+
next unless current["status"] == "waiting"
|
|
231
|
+
if ready?(store, current.fetch("id")) || deadline_exceeded?(store, current)
|
|
232
|
+
store.claim_turn(current.fetch("id"), from: "waiting", to: "pending")
|
|
233
|
+
else
|
|
234
|
+
store.claim_turn(current.fetch("id"), from: "waiting", to: "waiting") # rotate safely
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
elsif record["status"] == "running"
|
|
238
|
+
store.claim_turn(record.fetch("id"), from: "running", to: "running") # rotate without changing heartbeat
|
|
239
|
+
elsif record["status"] == "pending"
|
|
240
|
+
store.claim_turn(record.fetch("id"), from: "pending", to: "pending") # blocked conversations must rotate too
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
turns.group_by { |record| record.fetch("conversation_id") }.each do |conversation_id, records|
|
|
244
|
+
next if store.busy_conversation?(conversation_id, include_pending: false)
|
|
245
|
+
record = records.map { |row| store.load_turn(row.fetch("id")) }.find { |row| row["status"] == "pending" }
|
|
246
|
+
if record
|
|
247
|
+
rotated = store.claim_turn(record.fetch("id"), from: "pending", to: "pending")
|
|
248
|
+
enqueue(record.fetch("id")) if rotated
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def reconcile(before: Clock.now - (TurnKit.timeout || 300), store: TurnKit.store)
|
|
254
|
+
store.list_actionable_turns(limit: TurnKit.maintenance_batch_size).each do |record|
|
|
255
|
+
store.atomic(root_conversation(store, record)) do
|
|
256
|
+
record = store.load_turn(record.fetch("id"))
|
|
257
|
+
next unless record["status"] == "running" && record.fetch("heartbeat_at") < before
|
|
258
|
+
|
|
259
|
+
store.update_turn(record.fetch("id"), claim_token: nil)
|
|
260
|
+
# A pending child is known work, not an interrupted external effect.
|
|
261
|
+
executions = store.list_tool_executions(turn_id: record.fetch("id"))
|
|
262
|
+
executions.each do |execution|
|
|
263
|
+
next unless execution["status"] == "running"
|
|
264
|
+
loaded = Background.load_turn(record.fetch("id"), store: store)
|
|
265
|
+
tool = loaded.agent.effective_tools(turn: loaded).find { |candidate| candidate.tool_name == execution["tool_name"] }
|
|
266
|
+
recovery = tool.is_a?(Class) ? tool.recovery : tool&.class&.recovery
|
|
267
|
+
ordinary = tool && !(tool.is_a?(Class) && tool < SubAgentTool) &&
|
|
268
|
+
![WaitTool, LaunchAgentTool, SendMessageTool].include?(tool)
|
|
269
|
+
if ordinary && recovery == :replay_safe
|
|
270
|
+
store.claim_tool_execution(execution.fetch("id"), to: "pending", started_at: nil)
|
|
271
|
+
next
|
|
272
|
+
end
|
|
273
|
+
store.claim_tool_execution(execution.fetch("id"), to: "interrupted", error: { "message" => Reconciliation::INTERRUPTED_MESSAGE }, completed_at: Clock.now)
|
|
274
|
+
end
|
|
275
|
+
store.update_turn(record.fetch("id"), status: "pending", heartbeat_at: nil)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
drain(store: store)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
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/client.rb
CHANGED
|
@@ -4,7 +4,9 @@ module TurnKit
|
|
|
4
4
|
# The adapter contract. TurnKit calls clients with the full keyword
|
|
5
5
|
# signatures below. Custom adapters should subclass TurnKit::Client (or
|
|
6
6
|
# accept the same keywords) and must not execute tools themselves; TurnKit
|
|
7
|
-
# runs tools and persists their results.
|
|
7
|
+
# runs tools and persists their results. Messages may include :provider_parts
|
|
8
|
+
# for opaque continuation state from Result parts with type "provider".
|
|
9
|
+
# Adapters should consume only their own provider kind, never display it.
|
|
8
10
|
class Client
|
|
9
11
|
def validate!(model:)
|
|
10
12
|
true
|
data/lib/turnkit/conversation.rb
CHANGED
|
@@ -20,6 +20,55 @@ module TurnKit
|
|
|
20
20
|
append_message(role: "user", kind: "text", text: text, metadata: metadata)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Destination-oriented, durable next-turn input. The destination is also
|
|
24
|
+
# the delivery's source; the application need not create a sender agent.
|
|
25
|
+
def post(text, key:, principal: nil)
|
|
26
|
+
Authorization.authorize!(:send_message, principal: principal, source_conversation: id, destination_conversation: id)
|
|
27
|
+
delivery = store.create_delivery("source_conversation_id" => id, "destination_conversation_id" => id,
|
|
28
|
+
"key" => key, "payload" => { "text" => text, "principal" => principal })
|
|
29
|
+
Background.enqueue
|
|
30
|
+
delivery
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def messages_after(sequence, principal: nil)
|
|
34
|
+
Authorization.authorize!(:read_messages, principal: principal, destination_conversation: id)
|
|
35
|
+
messages.select { |message| message.sequence > sequence }.map do |message|
|
|
36
|
+
# Provider thinking/signature parts are not application progress.
|
|
37
|
+
attrs = message.to_h
|
|
38
|
+
attrs["content"] = Array(attrs["content"]).reject { |part| %w[thinking provider].include?(part["type"]) }
|
|
39
|
+
Message.new(attrs)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def input_status(delivery_id, principal: nil)
|
|
44
|
+
Authorization.authorize!(:read_control, principal: principal, destination_conversation: id)
|
|
45
|
+
delivery = store.load_delivery(delivery_id)
|
|
46
|
+
raise ArgumentError, "delivery belongs to another conversation" unless delivery["destination_conversation_id"] == id
|
|
47
|
+
applied = store.list_turns(conversation_id: id).filter_map do |row|
|
|
48
|
+
request = row.dig("options", "state", "delivery_requests", delivery_id)
|
|
49
|
+
{ "turn_id" => row.fetch("id"), "request_id" => request } if request
|
|
50
|
+
end.first
|
|
51
|
+
delivery.merge("application" => applied, "status" => applied ? "applied" : "pending")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def subject_prompt
|
|
55
|
+
subject.respond_to?(:to_prompt) ? subject.to_prompt.to_s : metadata["turnkit_subject_prompt"].to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def send_message(destination, text, key:, principal: nil)
|
|
59
|
+
Authorization.authorize!(:send_message, principal: principal, source_conversation: id, destination_conversation: destination.respond_to?(:id) ? destination.id : destination)
|
|
60
|
+
Background.send_message(source: id, destination: destination.respond_to?(:id) ? destination.id : destination,
|
|
61
|
+
text: text, key: key, store: store, principal: principal)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def inbox
|
|
65
|
+
store.list_deliveries(destination_conversation_id: id)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def outbox
|
|
69
|
+
store.list_deliveries(source_conversation_id: id)
|
|
70
|
+
end
|
|
71
|
+
|
|
23
72
|
def ask(text, async: false, **options)
|
|
24
73
|
trigger = say(text)
|
|
25
74
|
turn = build_turn(trigger_message_id: trigger.id, **options)
|
|
@@ -30,14 +79,18 @@ module TurnKit
|
|
|
30
79
|
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
80
|
end
|
|
32
81
|
|
|
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)
|
|
82
|
+
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
83
|
snapshot = latest_message_sequence
|
|
35
84
|
effective_thinking = thinking.equal?(THINKING_UNSET) ? agent.effective_thinking : Agent.normalize_thinking(thinking)
|
|
36
85
|
options = { "trigger_message_id" => trigger_message_id }.compact
|
|
86
|
+
options["budget_limits"] = agent.budget_limits.transform_keys(&:to_s)
|
|
37
87
|
options["thinking"] = effective_thinking
|
|
38
88
|
options["compact"] = compact unless compact.nil?
|
|
39
89
|
options["output_schema"] = output_schema || agent.output_schema if output_schema || agent.output_schema
|
|
40
90
|
options["prompt_mode"] = prompt_mode.to_sym if prompt_mode
|
|
91
|
+
options["context"] = JSON.parse(JSON.generate(context || metadata["turnkit_context"] || {}))
|
|
92
|
+
principal ||= metadata["principal"]
|
|
93
|
+
options["principal"] = JSON.parse(JSON.generate(principal)) unless principal.nil?
|
|
41
94
|
record = store.create_turn(
|
|
42
95
|
"conversation_id" => id,
|
|
43
96
|
"agent_name" => agent.name,
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
control = parent.control_boundary!
|
|
34
|
+
if control
|
|
35
|
+
child = control
|
|
36
|
+
next
|
|
37
|
+
end
|
|
38
|
+
existing = parent.store.list_turns(root_turn_id: parent.root_turn_id).find { |row| row["parent_tool_execution_id"] == context.execution.id }
|
|
39
|
+
if existing
|
|
40
|
+
child = existing
|
|
41
|
+
else
|
|
42
|
+
built = SubAgentTool.for(agent).build_child(task: task, context: context)
|
|
43
|
+
options = parent.store.load_turn(built.id).fetch("options")
|
|
44
|
+
options = options.merge("callback_conversation_id" => parent.conversation.id) if callback
|
|
45
|
+
child = parent.store.update_turn(built.id, submitted_at: Clock.now, options: options)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
return child if child.is_a?(Symbol)
|
|
49
|
+
Background.enqueue(child.fetch("id"))
|
|
50
|
+
SubAgentTool.result(child)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class WaitTool < Tool
|
|
55
|
+
tool_name "wait_for"
|
|
56
|
+
description "Suspend this background turn until all listed turns finish. Releases the worker while waiting."
|
|
57
|
+
parameter :turn_ids, :array, required: true
|
|
58
|
+
|
|
59
|
+
def call(turn_ids:, context:)
|
|
60
|
+
raise ToolError, "wait_for requires a background turn" unless context.turn.background?
|
|
61
|
+
ids = Background.wait(context.turn, turn_ids)
|
|
62
|
+
return :waiting unless Background.ready?(context.turn.store, context.turn.id)
|
|
63
|
+
|
|
64
|
+
{ "results" => ids.map { |id| SubAgentTool.result(context.turn.store.load_turn(id)) } }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/turnkit/cost.rb
CHANGED
|
@@ -73,13 +73,13 @@ module TurnKit
|
|
|
73
73
|
return new unless defined?(::RubyLLM) && model
|
|
74
74
|
|
|
75
75
|
model_info = ::RubyLLM.models.find(model)
|
|
76
|
-
tokens = ::RubyLLM::
|
|
77
|
-
input: usage.input_tokens,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
tokens = if ::RubyLLM::Chat.method_defined?(:generate)
|
|
77
|
+
::RubyLLM::Tokens.new(input: usage.input_tokens, output: usage.output_tokens + usage.thinking_tokens,
|
|
78
|
+
cache_read: usage.cached_tokens, cache_write: usage.cache_write_tokens, thinking: usage.thinking_tokens)
|
|
79
|
+
else
|
|
80
|
+
::RubyLLM::Tokens.new(input: usage.input_tokens, output: usage.output_tokens,
|
|
81
|
+
cached: usage.cached_tokens, cache_creation: usage.cache_write_tokens, thinking: usage.thinking_tokens)
|
|
82
|
+
end
|
|
83
83
|
from_hash(::RubyLLM::Cost.new(tokens: tokens, model: model_info).to_h)
|
|
84
84
|
rescue ::RubyLLM::ModelNotFoundError
|
|
85
85
|
new
|
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
|