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
data/lib/turnkit/store.rb CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  module TurnKit
4
4
  class Store
5
+ def atomic(_conversation_id, &) = raise(NotImplementedError)
6
+ def atomic_graph(&) = atomic(nil, &)
7
+
5
8
  def create_conversation(_attributes) = raise(NotImplementedError)
6
9
  def load_conversation(_id) = raise(NotImplementedError)
7
10
 
@@ -17,6 +20,28 @@ module TurnKit
17
20
  # is not in `from`), so concurrent workers cannot both claim a turn.
18
21
  def claim_turn(_id, from: "pending", to: "running", **_attributes) = raise(NotImplementedError)
19
22
  def list_turns(root_turn_id: nil, conversation_id: nil, agent_name: nil) = raise(NotImplementedError)
23
+ # Public inventory. Maintenance uses the explicitly bounded active scope.
24
+ def list_submitted_turns(limit: nil) = raise(NotImplementedError)
25
+ def list_actionable_turns(limit:) = raise(NotImplementedError)
26
+ def list_stale_inline_turns(before:, limit:) = raise(NotImplementedError)
27
+
28
+ # Stores may optimize these continuation queries without loading history.
29
+ def busy_conversation?(id, include_pending: true)
30
+ list_turns(conversation_id: id).any? { |row| %w[running waiting paused].include?(row["status"]) || (include_pending && row["submitted_at"] && row["status"] == "pending") }
31
+ end
32
+
33
+ def next_delivery_trigger(id)
34
+ consumed = list_turns(conversation_id: id).reject { |row| row["status"] == "pending" && !row["submitted_at"] }.map { |row| row["context_message_sequence"] }.max.to_i
35
+ list_messages(id).select { |row| row.dig("metadata", "delivery_id") && row["sequence"] > consumed }.last
36
+ end
37
+
38
+ def create_delivery(_attributes) = raise(NotImplementedError)
39
+ def load_delivery(_id) = raise(NotImplementedError)
40
+ def update_delivery(_id, _attributes) = raise(NotImplementedError)
41
+ def list_deliveries(source_conversation_id: nil, destination_conversation_id: nil, pending: false, limit: nil) = raise(NotImplementedError)
42
+
43
+ def create_wait(turn_id:, target_turn_id:) = raise(NotImplementedError)
44
+ def list_waits(turn_id: nil, target_turn_id: nil) = raise(NotImplementedError)
20
45
 
21
46
  def create_tool_execution(_attributes) = raise(NotImplementedError)
22
47
  def load_tool_execution(_id) = raise(NotImplementedError)
@@ -26,14 +51,18 @@ module TurnKit
26
51
  def claim_tool_execution(_id, from: "running", to: "completed", **_attributes) = raise(NotImplementedError)
27
52
  def list_tool_executions(turn_id:) = raise(NotImplementedError)
28
53
 
29
- # reconcile_stale_turns is the other concurrency-safety point: it must
30
- # atomically transition each pending/running turn whose stale anchor
31
- # (heartbeat_at, else started_at, else created_at) is older than `before`
32
- # to `stale`, rechecking both predicates at write time so a concurrently
33
- # claimed, heartbeated, or completed turn is never overwritten. Returns
34
- # the reconciled turn records. Descendant turns of a dead process stop
35
- # heartbeating too and are reconciled by the same predicate, so no
36
- # explicit subtree cascade is needed.
37
- def reconcile_stale_turns(before:) = []
54
+ # Inline abandoned work is fenced and left stale for application-directed
55
+ # continuation. Submitted work is resumed by Background.reconcile instead.
56
+ def reconcile_stale_turns(before:)
57
+ list_stale_inline_turns(before: before, limit: TurnKit.maintenance_batch_size).filter_map do |record|
58
+ atomic(Background.root_conversation(self, record)) do
59
+ current = load_turn(record.fetch("id"))
60
+ anchor = current["heartbeat_at"] || current["started_at"] || current["created_at"]
61
+ next if current["submitted_at"] || !%w[pending running].include?(current["status"]) || anchor >= before
62
+
63
+ update_turn(current.fetch("id"), status: "stale", claim_token: nil, completed_at: Clock.now)
64
+ end
65
+ end
66
+ end
38
67
  end
39
68
  end
@@ -17,17 +17,20 @@ module TurnKit
17
17
  end
18
18
  end
19
19
 
20
- def call(task:, context:)
21
- sub_agent = self.class.agent
20
+ def self.build_child(task:, context:)
21
+ sub_agent = agent
22
22
  parent_turn = context.turn
23
23
  lineage = {
24
24
  "parent_conversation_id" => parent_turn.conversation.id,
25
25
  "parent_turn_id" => parent_turn.id,
26
- "parent_tool_execution_id" => context.execution.id
26
+ "parent_tool_execution_id" => context.execution.id,
27
+ "principal" => context.principal
27
28
  }
28
- conversation = sub_agent.conversation(metadata: lineage)
29
+ store = parent_turn.store
30
+ record = store.create_conversation("agent_name" => sub_agent.name, "model" => sub_agent.effective_model, "metadata" => lineage)
31
+ conversation = Conversation.new(agent: sub_agent, record: record, store: store, model: sub_agent.effective_model, metadata: lineage)
29
32
  trigger = conversation.say(task, metadata: lineage)
30
- child = conversation.run!(
33
+ conversation.build_turn(
31
34
  trigger_message_id: trigger.id,
32
35
  budget: parent_turn.budget,
33
36
  parent_turn: parent_turn,
@@ -35,10 +38,23 @@ module TurnKit
35
38
  depth: parent_turn.depth + 1,
36
39
  model: sub_agent.effective_model,
37
40
  agent: sub_agent,
41
+ principal: context.principal,
38
42
  on_event: parent_turn.agent.effective_on_event
39
43
  )
40
- error = child.store.load_turn(child.id)["error"] if child.failed?
41
- { "conversation_id" => conversation.id, "turn_id" => child.id, "status" => child.status, "result" => child.output_text, "output_data" => child.output_data, "error" => error }.compact
44
+ end
45
+
46
+ def self.result(record)
47
+ { "conversation_id" => record.fetch("conversation_id"), "turn_id" => record.fetch("id"),
48
+ "status" => record.fetch("status"), "result" => record["output_text"].to_s,
49
+ "output_data" => record["output_data"], "error" => record["error"] }.compact
50
+ end
51
+
52
+ def call(task:, context:)
53
+ Authorization.authorize!(:launch_agent, principal: context.principal, turn: context.turn,
54
+ agent: self.class.agent, arguments: { "task" => task })
55
+ child = self.class.build_child(task: task, context: context)
56
+ child.run!
57
+ SubAgentTool.result(child.store.load_turn(child.id))
42
58
  end
43
59
  end
44
60
  end
@@ -7,7 +7,7 @@ module TurnKit
7
7
  PROMPT_MODES = %i[full minimal task none].freeze
8
8
  MODE_SECTIONS = {
9
9
  full: DEFAULT_SECTIONS,
10
- minimal: %i[agent sub_agent instructions behavior tools environment],
10
+ minimal: %i[agent sub_agent instructions behavior loaded_skills available_skills tools environment],
11
11
  task: DEFAULT_SECTIONS,
12
12
  none: []
13
13
  }.freeze
@@ -181,7 +181,7 @@ module TurnKit
181
181
  end
182
182
 
183
183
  def tools_section
184
- tools = agent.effective_tools
184
+ tools = agent.effective_tools(turn: turn)
185
185
 
186
186
  if tools.empty?
187
187
  tagged("tools_available", "(none)\n\nNo tools are available for this turn.")
@@ -197,9 +197,7 @@ module TurnKit
197
197
  end
198
198
 
199
199
  def subject_section
200
- return nil unless conversation.subject&.respond_to?(:to_prompt)
201
-
202
- value = conversation.subject.to_prompt.to_s.strip
200
+ value = conversation.subject_prompt.strip
203
201
  return nil if value.empty?
204
202
 
205
203
  untrusted_section(
@@ -211,9 +209,11 @@ module TurnKit
211
209
  end
212
210
 
213
211
  def live_context_section
214
- contributions = Array(TurnKit.context_contributors).filter_map do |contributor|
212
+ contributors = agent.context_contributors
213
+ contributions = contributors.filter_map do |contributor|
215
214
  normalize_context_contribution(contributor.call(prompt_build_context))
216
215
  end
216
+ contributions.unshift(normalize_context_contribution(name: "run_context", content: JSON.generate(turn.context), trusted: false)) unless turn.context.empty?
217
217
  return nil if contributions.empty?
218
218
 
219
219
  body = contributions.map do |contribution|
@@ -280,7 +280,7 @@ module TurnKit
280
280
  "stable_chars" => stable.length,
281
281
  "dynamic_chars" => dynamic.length,
282
282
  "sections" => sections.map(&:to_s),
283
- "tool_count" => agent.effective_tools.length
283
+ "tool_count" => agent.effective_tools(turn: turn).length
284
284
  }
285
285
  end
286
286
 
data/lib/turnkit/tool.rb CHANGED
@@ -54,6 +54,17 @@ module TurnKit
54
54
  @ends_turn || false
55
55
  end
56
56
 
57
+ # :unknown is the safe default for external effects. :replay_safe means
58
+ # the application/tool honors ToolContext#idempotency_key on retries.
59
+ def recovery(value = nil)
60
+ if value
61
+ value = value.to_sym
62
+ raise ArgumentError, "recovery must be :unknown or :replay_safe" unless %i[unknown replay_safe].include?(value)
63
+ @recovery = value
64
+ end
65
+ @recovery || (superclass.respond_to?(:recovery) ? superclass.recovery : :unknown)
66
+ end
67
+
57
68
  def completion_message(result)
58
69
  case @completion_message
59
70
  when nil
@@ -7,14 +7,25 @@ module TurnKit
7
7
  end
8
8
 
9
9
  def dispatch(tool_calls)
10
+ waiting = false
10
11
  tool_calls.each_with_index do |tool_call, index|
11
- execution = run(tool_call)
12
+ control = turn.control_boundary!
13
+ return control if control
14
+ # Fan out a contiguous group of subagents, but never reorder ordinary
15
+ # tools across it or execute past a terminal tool.
16
+ return :waiting if waiting && !subagent?(tool_for(tool_call.name))
17
+ execution = run(tool_call, defer_result: waiting)
18
+ return execution if %i[paused steered].include?(execution)
19
+ if execution == :waiting
20
+ waiting = true
21
+ next
22
+ end
12
23
  if execution.completed? && tool_for(tool_call.name)&.ends_turn?
13
24
  skip_remaining(tool_calls.drop(index + 1), terminal: tool_call)
14
25
  return execution
15
26
  end
16
27
  end
17
- nil
28
+ waiting ? :waiting : nil
18
29
  end
19
30
 
20
31
  def completion_message(execution)
@@ -25,11 +36,30 @@ module TurnKit
25
36
  private
26
37
  attr_reader :turn
27
38
 
28
- def run(tool_call)
29
- execution = ToolExecution.new(create_execution(tool_call))
30
- heartbeat!
31
-
39
+ def run(tool_call, defer_result: false)
40
+ @defer_result = defer_result
32
41
  tool = tool_for(tool_call.name)
42
+ existing = turn.store.list_tool_executions(turn_id: turn.id).find { |row| row["tool_call_id"] == tool_call.id }
43
+ if existing && !%w[pending running].include?(existing["status"])
44
+ execution = ToolExecution.new(existing)
45
+ append_result_once(execution, tool_call, execution.result || execution.error, error: !execution.completed? && !execution.cancelled?)
46
+ return execution
47
+ end
48
+
49
+ denied = nil
50
+ execution = turn.store.atomic do
51
+ execution = ToolExecution.new(existing || create_execution(tool_call))
52
+ unless existing
53
+ begin
54
+ turn.execution_budget(excluding: execution.id).count_tool_execution!(tool_call.name)
55
+ rescue BudgetError => error
56
+ denied = error
57
+ finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
58
+ end
59
+ end
60
+ execution
61
+ end
62
+ raise denied if denied
33
63
 
34
64
  unless tool
35
65
  return finish_error(execution, tool_call, "unknown tool: #{tool_call.name}")
@@ -39,20 +69,35 @@ module TurnKit
39
69
  return finish_error(execution, tool_call, tool_call.arguments_error)
40
70
  end
41
71
 
42
- begin
43
- turn.budget.count_tool_execution!(tool_call.name)
44
- rescue BudgetError => error
45
- finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
46
- raise
72
+ if execution.status == "pending" && !subagent?(tool) && ![WaitTool, LaunchAgentTool, SendMessageTool].include?(tool)
73
+ claimed = turn.store.claim_tool_execution(execution.id, from: "pending", to: "running", started_at: Clock.now)
74
+ raise LostClaim, "tool execution claim was revoked" unless claimed
75
+ execution = ToolExecution.new(claimed)
47
76
  end
48
77
 
49
78
  context = ToolContext.new(turn: turn, execution: execution)
50
79
  payload = begin
51
- normalize_payload(call_tool(tool, tool_call.arguments, context: context))
80
+ Authorization.authorize!(:tool, principal: context.principal, turn: turn, tool: tool, arguments: tool_call.arguments)
81
+ # Observe cancellation/reconciliation immediately before crossing the
82
+ # external-effect boundary. Calls already sent cannot be recalled.
83
+ control = turn.control_boundary!
84
+ return control if control
85
+ if turn.background? && subagent?(tool)
86
+ return delegate(tool, tool_call, context)
87
+ end
88
+ value = call_tool(tool, tool_call.arguments, context: context)
89
+ return value if tool == LaunchAgentTool && %i[paused steered waiting].include?(value)
90
+ return :waiting if value == :waiting && tool == WaitTool
91
+ normalize_payload(value)
92
+ rescue LostClaim
93
+ raise
52
94
  rescue BudgetError => error
53
95
  finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
54
96
  raise
97
+ rescue AuthorizationError => error
98
+ return finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "authorization_denied" => true })
55
99
  rescue StandardError => error
100
+ raise if turn.background? && !error.is_a?(ToolError)
56
101
  return finish_error(execution, tool_call, error.message, details: { "class" => error.class.name })
57
102
  end
58
103
  finish_success(execution, tool_call, payload)
@@ -63,7 +108,7 @@ module TurnKit
63
108
  "turn_id" => turn.id,
64
109
  "tool_call_id" => tool_call.id,
65
110
  "tool_name" => tool_call.name,
66
- "status" => "running",
111
+ "status" => turn.background? && (subagent?(tool_for(tool_call.name)) || [WaitTool, LaunchAgentTool, SendMessageTool].include?(tool_for(tool_call.name))) ? "pending" : "running",
67
112
  "arguments" => tool_call.arguments,
68
113
  "started_at" => Clock.now
69
114
  )
@@ -71,11 +116,12 @@ module TurnKit
71
116
 
72
117
  def finish_success(execution, tool_call, payload)
73
118
  json = payload.to_json
74
- attrs = turn.store.claim_tool_execution(execution.id, from: "running", to: "completed", result: payload, completed_at: Clock.now)
119
+ attrs = turn.store.atomic do
120
+ row = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "completed", result: payload, completed_at: Clock.now)
121
+ append_result_once(execution, tool_call, payload) if row
122
+ row
123
+ end
75
124
  return superseded_execution(execution) unless attrs
76
-
77
- append_result(execution, tool_call, payload, json: json, error: false)
78
- heartbeat!
79
125
  turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: json.length)
80
126
  ToolExecution.new(attrs)
81
127
  end
@@ -83,11 +129,12 @@ module TurnKit
83
129
  def finish_error(execution, tool_call, message, details: nil)
84
130
  error = { "message" => message.to_s, "details" => details }.compact
85
131
  json = error.to_json
86
- attrs = turn.store.claim_tool_execution(execution.id, from: "running", to: "failed", error: error, completed_at: Clock.now)
132
+ attrs = turn.store.atomic do
133
+ row = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "failed", error: error, completed_at: Clock.now)
134
+ append_result_once(execution, tool_call, error, error: true) if row
135
+ row
136
+ end
87
137
  return superseded_execution(execution) unless attrs
88
-
89
- append_result(execution, tool_call, error, json: json, error: true)
90
- heartbeat!
91
138
  turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: json.length)
92
139
  ToolExecution.new(attrs)
93
140
  end
@@ -98,11 +145,14 @@ module TurnKit
98
145
  ToolExecution.new(turn.store.load_tool_execution(execution.id))
99
146
  end
100
147
 
101
- def append_result(execution, tool_call, payload, json: payload.to_json, error: false)
148
+ def append_result_once(execution, tool_call, payload, error: false)
149
+ return if @defer_result
150
+ return if turn.store.list_messages(turn.conversation.id).any? { |row| row["tool_execution_id"] == execution.id }
151
+
102
152
  message = turn.conversation.append_message(
103
153
  role: "tool",
104
154
  kind: "tool_result",
105
- content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => json, "error" => error } ],
155
+ content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => payload.to_json, "error" => error } ],
106
156
  turn_id: turn.id,
107
157
  tool_execution_id: execution.id,
108
158
  metadata: { "tool_name" => tool_call.name }
@@ -112,42 +162,56 @@ module TurnKit
112
162
 
113
163
  def skip_remaining(calls, terminal:)
114
164
  calls.each do |call|
115
- payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
116
- execution = ToolExecution.new(create_execution(call))
117
- attrs = turn.store.claim_tool_execution(execution.id, from: "running", to: "cancelled", result: payload, completed_at: Clock.now)
118
- next unless attrs
119
-
120
- append_result(ToolExecution.new(attrs), call, payload)
121
- turn.emit("tool_call.skipped", id: call.id, name: call.name)
165
+ turn.store.atomic do
166
+ next if turn.store.list_tool_executions(turn_id: turn.id).any? { |row| row["tool_call_id"] == call.id }
167
+ payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
168
+ execution = ToolExecution.new(create_execution(call))
169
+ attrs = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "cancelled", result: payload, completed_at: Clock.now)
170
+ append_result_once(ToolExecution.new(attrs), call, payload)
171
+ turn.emit("tool_call.skipped", id: call.id, name: call.name)
172
+ end
122
173
  end
123
174
  end
124
175
 
125
- def heartbeat!
126
- turn.send(:heartbeat!)
176
+ def subagent?(tool)
177
+ tool.is_a?(Class) && tool < SubAgentTool
178
+ end
179
+
180
+ def delegate(tool, call, context)
181
+ arguments = tool.validate_arguments(call.arguments)
182
+ Authorization.authorize!(:launch_agent, principal: context.principal, turn: turn, agent: tool.agent, arguments: arguments)
183
+ TurnKit.resolve_agent(tool.agent.name)
184
+ child = turn.store.atomic_graph do
185
+ turn.store.atomic(Background.root_conversation(turn.store, turn.store.load_turn(turn.id))) do
186
+ control = turn.control_boundary!
187
+ next control if control
188
+ row = turn.store.list_turns(root_turn_id: turn.root_turn_id).find { |candidate| candidate["parent_tool_execution_id"] == context.execution.id }
189
+ unless row
190
+ built = tool.build_child(task: arguments.fetch("task"), context: context)
191
+ row = turn.store.update_turn(built.id, submitted_at: Clock.now)
192
+ end
193
+ Background.wait(turn, [row.fetch("id")])
194
+ row
195
+ end
196
+ end
197
+ return child if child.is_a?(Symbol)
198
+ unless Background::TERMINAL.include?(child["status"])
199
+ Background.enqueue(child.fetch("id")) if child["status"] == "pending"
200
+ return :waiting
201
+ end
202
+ finish_success(context.execution, call, SubAgentTool.result(child))
127
203
  end
128
204
 
129
205
  def tool_for(name)
130
- turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
206
+ turn.agent.effective_tools(turn: turn).find { |tool| tool.tool_name == name.to_s }
131
207
  end
132
208
 
133
- # Heartbeats while the tool runs so a tool slower than TurnKit.timeout
134
- # keeps its turn's stale anchor fresh and is not falsely reconciled.
135
209
  def call_tool(tool, arguments, context:)
136
- interval = (TurnKit.timeout || 300) / 3.0
137
- heartbeat = Thread.new do
138
- loop do
139
- sleep interval
140
- heartbeat!
141
- end
142
- end
143
-
144
210
  if tool.is_a?(Class)
145
211
  tool.call(arguments, context: context)
146
212
  else
147
213
  tool.class.invoke(tool, arguments, context: context)
148
214
  end
149
- ensure
150
- heartbeat.kill
151
215
  end
152
216
 
153
217
  def normalize_payload(value)