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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +197 -1
  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 +130 -10
  13. data/lib/turnkit/adapters/ruby_llm.rb +14 -0
  14. data/lib/turnkit/agent.rb +26 -20
  15. data/lib/turnkit/authorization.rb +17 -0
  16. data/lib/turnkit/background.rb +281 -0
  17. data/lib/turnkit/budget.rb +4 -3
  18. data/lib/turnkit/conversation.rb +23 -1
  19. data/lib/turnkit/coordination_tools.rb +61 -0
  20. data/lib/turnkit/error.rb +3 -0
  21. data/lib/turnkit/execution_store.rb +30 -0
  22. data/lib/turnkit/id.rb +1 -0
  23. data/lib/turnkit/image_tool.rb +10 -0
  24. data/lib/turnkit/job.rb +21 -0
  25. data/lib/turnkit/memory_store.rb +110 -14
  26. data/lib/turnkit/reconciliation.rb +75 -0
  27. data/lib/turnkit/record.rb +37 -4
  28. data/lib/turnkit/run.rb +15 -0
  29. data/lib/turnkit/skill.rb +5 -4
  30. data/lib/turnkit/specialists.rb +254 -0
  31. data/lib/turnkit/store.rb +42 -2
  32. data/lib/turnkit/sub_agent_tool.rb +23 -7
  33. data/lib/turnkit/system_prompt.rb +7 -7
  34. data/lib/turnkit/tool.rb +11 -0
  35. data/lib/turnkit/tool_runner.rb +109 -29
  36. data/lib/turnkit/turn.rb +188 -57
  37. data/lib/turnkit/version.rb +1 -1
  38. data/lib/turnkit.rb +32 -3
  39. metadata +16 -5
@@ -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
@@ -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
@@ -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
@@ -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 = Mutex.new
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 update_tool_execution(id, attributes)
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
@@ -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?