turnkit 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +198 -5
  4. data/UPGRADE.md +57 -0
  5. data/lib/generators/turnkit/install/templates/create_turnkit_tables.rb +30 -0
  6. data/lib/generators/turnkit/install/templates/delivery.rb +7 -0
  7. data/lib/generators/turnkit/install/templates/initializer.rb +5 -0
  8. data/lib/generators/turnkit/install/templates/wait.rb +7 -0
  9. data/lib/generators/turnkit/install_generator.rb +2 -0
  10. data/lib/generators/turnkit/upgrade/templates/add_turnkit_durable_orchestration.rb +36 -0
  11. data/lib/generators/turnkit/upgrade_generator.rb +34 -0
  12. data/lib/turnkit/active_record_store.rb +124 -14
  13. data/lib/turnkit/adapters/ruby_llm.rb +107 -21
  14. data/lib/turnkit/agent.rb +26 -20
  15. data/lib/turnkit/authorization.rb +17 -0
  16. data/lib/turnkit/background.rb +281 -0
  17. data/lib/turnkit/budget.rb +4 -3
  18. data/lib/turnkit/client.rb +3 -1
  19. data/lib/turnkit/conversation.rb +54 -1
  20. data/lib/turnkit/coordination_tools.rb +67 -0
  21. data/lib/turnkit/cost.rb +7 -7
  22. data/lib/turnkit/error.rb +3 -0
  23. data/lib/turnkit/execution_store.rb +30 -0
  24. data/lib/turnkit/id.rb +1 -0
  25. data/lib/turnkit/image_tool.rb +10 -0
  26. data/lib/turnkit/job.rb +21 -0
  27. data/lib/turnkit/memory_store.rb +113 -20
  28. data/lib/turnkit/message_projection.rb +4 -1
  29. data/lib/turnkit/reconciliation.rb +13 -10
  30. data/lib/turnkit/record.rb +36 -3
  31. data/lib/turnkit/run.rb +28 -0
  32. data/lib/turnkit/skill.rb +5 -4
  33. data/lib/turnkit/specialists.rb +254 -0
  34. data/lib/turnkit/store.rb +38 -9
  35. data/lib/turnkit/sub_agent_tool.rb +23 -7
  36. data/lib/turnkit/system_prompt.rb +7 -7
  37. data/lib/turnkit/tool.rb +11 -0
  38. data/lib/turnkit/tool_runner.rb +109 -45
  39. data/lib/turnkit/turn.rb +238 -61
  40. data/lib/turnkit/turn_controls.rb +136 -0
  41. data/lib/turnkit/version.rb +1 -1
  42. data/lib/turnkit.rb +31 -0
  43. metadata +16 -5
@@ -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
@@ -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
- completed = Array(tool_executions).select { |execution| %w[completed failed].include?(execution["status"]) && !execution.dig("error", "details", "budget_denied") }
34
- @tool_executions = completed.length
35
- completed.each { |execution| @tool_executions_by_name[execution.fetch("tool_name").to_s] += 1 }
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
@@ -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
@@ -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::Tokens.new(
77
- input: usage.input_tokens,
78
- output: usage.output_tokens,
79
- cached: usage.cached_tokens,
80
- cache_creation: usage.cache_write_tokens,
81
- thinking: usage.thinking_tokens
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
@@ -6,6 +6,7 @@ module TurnKit
6
6
  conversation: "conv",
7
7
  message: "msg",
8
8
  turn: "turn",
9
+ delivery: "delivery",
9
10
  tool_execution: "tool"
10
11
  }.freeze
11
12
 
@@ -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
@@ -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