turnkit 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +187 -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 +14 -0
- data/lib/turnkit/agent.rb +26 -20
- data/lib/turnkit/authorization.rb +17 -0
- data/lib/turnkit/background.rb +281 -0
- data/lib/turnkit/budget.rb +4 -3
- data/lib/turnkit/conversation.rb +23 -1
- data/lib/turnkit/coordination_tools.rb +61 -0
- data/lib/turnkit/error.rb +3 -0
- data/lib/turnkit/execution_store.rb +30 -0
- data/lib/turnkit/id.rb +1 -0
- data/lib/turnkit/image_tool.rb +10 -0
- data/lib/turnkit/job.rb +21 -0
- data/lib/turnkit/memory_store.rb +106 -15
- data/lib/turnkit/reconciliation.rb +13 -10
- data/lib/turnkit/record.rb +36 -3
- data/lib/turnkit/run.rb +15 -0
- data/lib/turnkit/skill.rb +5 -4
- data/lib/turnkit/specialists.rb +254 -0
- data/lib/turnkit/store.rb +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 +101 -45
- data/lib/turnkit/turn.rb +188 -57
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +30 -0
- metadata +15 -5
data/lib/turnkit/tool_runner.rb
CHANGED
|
@@ -7,14 +7,22 @@ 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
|
-
|
|
12
|
+
# Fan out a contiguous group of subagents, but never reorder ordinary
|
|
13
|
+
# tools across it or execute past a terminal tool.
|
|
14
|
+
return :waiting if waiting && !subagent?(tool_for(tool_call.name))
|
|
15
|
+
execution = run(tool_call, defer_result: waiting)
|
|
16
|
+
if execution == :waiting
|
|
17
|
+
waiting = true
|
|
18
|
+
next
|
|
19
|
+
end
|
|
12
20
|
if execution.completed? && tool_for(tool_call.name)&.ends_turn?
|
|
13
21
|
skip_remaining(tool_calls.drop(index + 1), terminal: tool_call)
|
|
14
22
|
return execution
|
|
15
23
|
end
|
|
16
24
|
end
|
|
17
|
-
nil
|
|
25
|
+
waiting ? :waiting : nil
|
|
18
26
|
end
|
|
19
27
|
|
|
20
28
|
def completion_message(execution)
|
|
@@ -25,11 +33,30 @@ module TurnKit
|
|
|
25
33
|
private
|
|
26
34
|
attr_reader :turn
|
|
27
35
|
|
|
28
|
-
def run(tool_call)
|
|
29
|
-
|
|
30
|
-
heartbeat!
|
|
31
|
-
|
|
36
|
+
def run(tool_call, defer_result: false)
|
|
37
|
+
@defer_result = defer_result
|
|
32
38
|
tool = tool_for(tool_call.name)
|
|
39
|
+
existing = turn.store.list_tool_executions(turn_id: turn.id).find { |row| row["tool_call_id"] == tool_call.id }
|
|
40
|
+
if existing && !%w[pending running].include?(existing["status"])
|
|
41
|
+
execution = ToolExecution.new(existing)
|
|
42
|
+
append_result_once(execution, tool_call, execution.result || execution.error, error: !execution.completed? && !execution.cancelled?)
|
|
43
|
+
return execution
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
denied = nil
|
|
47
|
+
execution = turn.store.atomic do
|
|
48
|
+
execution = ToolExecution.new(existing || create_execution(tool_call))
|
|
49
|
+
unless existing
|
|
50
|
+
begin
|
|
51
|
+
turn.execution_budget(excluding: execution.id).count_tool_execution!(tool_call.name)
|
|
52
|
+
rescue BudgetError => error
|
|
53
|
+
denied = error
|
|
54
|
+
finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
execution
|
|
58
|
+
end
|
|
59
|
+
raise denied if denied
|
|
33
60
|
|
|
34
61
|
unless tool
|
|
35
62
|
return finish_error(execution, tool_call, "unknown tool: #{tool_call.name}")
|
|
@@ -39,20 +66,33 @@ module TurnKit
|
|
|
39
66
|
return finish_error(execution, tool_call, tool_call.arguments_error)
|
|
40
67
|
end
|
|
41
68
|
|
|
42
|
-
|
|
43
|
-
turn.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
raise
|
|
69
|
+
if execution.status == "pending" && !subagent?(tool) && ![WaitTool, LaunchAgentTool, SendMessageTool].include?(tool)
|
|
70
|
+
claimed = turn.store.claim_tool_execution(execution.id, from: "pending", to: "running", started_at: Clock.now)
|
|
71
|
+
raise LostClaim, "tool execution claim was revoked" unless claimed
|
|
72
|
+
execution = ToolExecution.new(claimed)
|
|
47
73
|
end
|
|
48
74
|
|
|
49
75
|
context = ToolContext.new(turn: turn, execution: execution)
|
|
50
76
|
payload = begin
|
|
51
|
-
|
|
77
|
+
Authorization.authorize!(:tool, principal: context.principal, turn: turn, tool: tool, arguments: tool_call.arguments)
|
|
78
|
+
# Observe cancellation/reconciliation immediately before crossing the
|
|
79
|
+
# external-effect boundary. Calls already sent cannot be recalled.
|
|
80
|
+
turn.store.atomic { true }
|
|
81
|
+
if turn.background? && subagent?(tool)
|
|
82
|
+
return delegate(tool, tool_call, context)
|
|
83
|
+
end
|
|
84
|
+
value = call_tool(tool, tool_call.arguments, context: context)
|
|
85
|
+
return :waiting if value == :waiting && tool == WaitTool
|
|
86
|
+
normalize_payload(value)
|
|
87
|
+
rescue LostClaim
|
|
88
|
+
raise
|
|
52
89
|
rescue BudgetError => error
|
|
53
90
|
finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "budget_denied" => true })
|
|
54
91
|
raise
|
|
92
|
+
rescue AuthorizationError => error
|
|
93
|
+
return finish_error(execution, tool_call, error.message, details: { "class" => error.class.name, "authorization_denied" => true })
|
|
55
94
|
rescue StandardError => error
|
|
95
|
+
raise if turn.background? && !error.is_a?(ToolError)
|
|
56
96
|
return finish_error(execution, tool_call, error.message, details: { "class" => error.class.name })
|
|
57
97
|
end
|
|
58
98
|
finish_success(execution, tool_call, payload)
|
|
@@ -63,7 +103,7 @@ module TurnKit
|
|
|
63
103
|
"turn_id" => turn.id,
|
|
64
104
|
"tool_call_id" => tool_call.id,
|
|
65
105
|
"tool_name" => tool_call.name,
|
|
66
|
-
"status" => "running",
|
|
106
|
+
"status" => turn.background? && (subagent?(tool_for(tool_call.name)) || [WaitTool, LaunchAgentTool, SendMessageTool].include?(tool_for(tool_call.name))) ? "pending" : "running",
|
|
67
107
|
"arguments" => tool_call.arguments,
|
|
68
108
|
"started_at" => Clock.now
|
|
69
109
|
)
|
|
@@ -71,11 +111,12 @@ module TurnKit
|
|
|
71
111
|
|
|
72
112
|
def finish_success(execution, tool_call, payload)
|
|
73
113
|
json = payload.to_json
|
|
74
|
-
attrs = turn.store.
|
|
114
|
+
attrs = turn.store.atomic do
|
|
115
|
+
row = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "completed", result: payload, completed_at: Clock.now)
|
|
116
|
+
append_result_once(execution, tool_call, payload) if row
|
|
117
|
+
row
|
|
118
|
+
end
|
|
75
119
|
return superseded_execution(execution) unless attrs
|
|
76
|
-
|
|
77
|
-
append_result(execution, tool_call, payload, json: json, error: false)
|
|
78
|
-
heartbeat!
|
|
79
120
|
turn.emit("tool_call.completed", id: tool_call.id, name: tool_call.name, result_chars: json.length)
|
|
80
121
|
ToolExecution.new(attrs)
|
|
81
122
|
end
|
|
@@ -83,11 +124,12 @@ module TurnKit
|
|
|
83
124
|
def finish_error(execution, tool_call, message, details: nil)
|
|
84
125
|
error = { "message" => message.to_s, "details" => details }.compact
|
|
85
126
|
json = error.to_json
|
|
86
|
-
attrs = turn.store.
|
|
127
|
+
attrs = turn.store.atomic do
|
|
128
|
+
row = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "failed", error: error, completed_at: Clock.now)
|
|
129
|
+
append_result_once(execution, tool_call, error, error: true) if row
|
|
130
|
+
row
|
|
131
|
+
end
|
|
87
132
|
return superseded_execution(execution) unless attrs
|
|
88
|
-
|
|
89
|
-
append_result(execution, tool_call, error, json: json, error: true)
|
|
90
|
-
heartbeat!
|
|
91
133
|
turn.emit("tool_call.failed", id: tool_call.id, name: tool_call.name, error: error, result_chars: json.length)
|
|
92
134
|
ToolExecution.new(attrs)
|
|
93
135
|
end
|
|
@@ -98,11 +140,14 @@ module TurnKit
|
|
|
98
140
|
ToolExecution.new(turn.store.load_tool_execution(execution.id))
|
|
99
141
|
end
|
|
100
142
|
|
|
101
|
-
def
|
|
143
|
+
def append_result_once(execution, tool_call, payload, error: false)
|
|
144
|
+
return if @defer_result
|
|
145
|
+
return if turn.store.list_messages(turn.conversation.id).any? { |row| row["tool_execution_id"] == execution.id }
|
|
146
|
+
|
|
102
147
|
message = turn.conversation.append_message(
|
|
103
148
|
role: "tool",
|
|
104
149
|
kind: "tool_result",
|
|
105
|
-
content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" =>
|
|
150
|
+
content: [ { "type" => "tool_result", "tool_call_id" => tool_call.id, "text" => payload.to_json, "error" => error } ],
|
|
106
151
|
turn_id: turn.id,
|
|
107
152
|
tool_execution_id: execution.id,
|
|
108
153
|
metadata: { "tool_name" => tool_call.name }
|
|
@@ -112,42 +157,53 @@ module TurnKit
|
|
|
112
157
|
|
|
113
158
|
def skip_remaining(calls, terminal:)
|
|
114
159
|
calls.each do |call|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
160
|
+
turn.store.atomic do
|
|
161
|
+
next if turn.store.list_tool_executions(turn_id: turn.id).any? { |row| row["tool_call_id"] == call.id }
|
|
162
|
+
payload = { "skipped" => true, "message" => "not executed: turn ended by #{terminal.name}" }
|
|
163
|
+
execution = ToolExecution.new(create_execution(call))
|
|
164
|
+
attrs = turn.store.claim_tool_execution(execution.id, from: execution.status, to: "cancelled", result: payload, completed_at: Clock.now)
|
|
165
|
+
append_result_once(ToolExecution.new(attrs), call, payload)
|
|
166
|
+
turn.emit("tool_call.skipped", id: call.id, name: call.name)
|
|
167
|
+
end
|
|
122
168
|
end
|
|
123
169
|
end
|
|
124
170
|
|
|
125
|
-
def
|
|
126
|
-
|
|
171
|
+
def subagent?(tool)
|
|
172
|
+
tool.is_a?(Class) && tool < SubAgentTool
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def delegate(tool, call, context)
|
|
176
|
+
arguments = tool.validate_arguments(call.arguments)
|
|
177
|
+
Authorization.authorize!(:launch_agent, principal: context.principal, turn: turn, agent: tool.agent, arguments: arguments)
|
|
178
|
+
TurnKit.resolve_agent(tool.agent.name)
|
|
179
|
+
child = turn.store.atomic_graph do
|
|
180
|
+
turn.store.atomic(Background.root_conversation(turn.store, turn.store.load_turn(turn.id))) do
|
|
181
|
+
row = turn.store.list_turns(root_turn_id: turn.root_turn_id).find { |candidate| candidate["parent_tool_execution_id"] == context.execution.id }
|
|
182
|
+
unless row
|
|
183
|
+
built = tool.build_child(task: arguments.fetch("task"), context: context)
|
|
184
|
+
row = turn.store.update_turn(built.id, submitted_at: Clock.now)
|
|
185
|
+
end
|
|
186
|
+
Background.wait(turn, [row.fetch("id")])
|
|
187
|
+
row
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
unless Background::TERMINAL.include?(child["status"])
|
|
191
|
+
Background.enqueue(child.fetch("id")) if child["status"] == "pending"
|
|
192
|
+
return :waiting
|
|
193
|
+
end
|
|
194
|
+
finish_success(context.execution, call, SubAgentTool.result(child))
|
|
127
195
|
end
|
|
128
196
|
|
|
129
197
|
def tool_for(name)
|
|
130
|
-
turn.agent.effective_tools.find { |tool| tool.tool_name == name.to_s }
|
|
198
|
+
turn.agent.effective_tools(turn: turn).find { |tool| tool.tool_name == name.to_s }
|
|
131
199
|
end
|
|
132
200
|
|
|
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
201
|
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
202
|
if tool.is_a?(Class)
|
|
145
203
|
tool.call(arguments, context: context)
|
|
146
204
|
else
|
|
147
205
|
tool.class.invoke(tool, arguments, context: context)
|
|
148
206
|
end
|
|
149
|
-
ensure
|
|
150
|
-
heartbeat.kill
|
|
151
207
|
end
|
|
152
208
|
|
|
153
209
|
def normalize_payload(value)
|
data/lib/turnkit/turn.rb
CHANGED
|
@@ -13,6 +13,7 @@ module TurnKit
|
|
|
13
13
|
@agent = agent
|
|
14
14
|
@conversation = conversation
|
|
15
15
|
@store = store
|
|
16
|
+
@base_store = store
|
|
16
17
|
@record = record.transform_keys(&:to_s)
|
|
17
18
|
@id = @record.fetch("id")
|
|
18
19
|
@conversation_id = @record.fetch("conversation_id")
|
|
@@ -36,62 +37,89 @@ module TurnKit
|
|
|
36
37
|
@on_event = block if block
|
|
37
38
|
return self unless status == "pending"
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
@store = @base_store
|
|
41
|
+
claimed = Background.claim(store, @record)
|
|
40
42
|
return self unless claimed
|
|
41
43
|
|
|
42
44
|
@record = claimed
|
|
43
45
|
@started_at = @record["started_at"]
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
budget.check!(depth: depth)
|
|
50
|
-
count_iteration!
|
|
51
|
-
TurnKit::Compaction.maybe_compact!(self)
|
|
52
|
-
|
|
53
|
-
request = model_request
|
|
54
|
-
emit_model_requested("model.requested", request)
|
|
55
|
-
result = call_client(request)
|
|
56
|
-
result_cost = Cost.from_usage(result.usage, model: result.model || model)
|
|
57
|
-
|
|
58
|
-
add_usage!(result.usage, cost: result_cost)
|
|
59
|
-
emit_model_completed("model.completed", result, result_cost, model: model)
|
|
60
|
-
budget.add_cost!(result_cost.total)
|
|
61
|
-
persist_assistant_message(result)
|
|
62
|
-
|
|
63
|
-
if result.tool_calls?
|
|
64
|
-
runner = ToolRunner.new(self)
|
|
65
|
-
terminal = runner.dispatch(result.tool_calls)
|
|
66
|
-
if terminal
|
|
67
|
-
candidate = append_terminal_completion(runner, terminal)
|
|
68
|
-
else
|
|
69
|
-
next
|
|
70
|
-
end
|
|
71
|
-
else
|
|
72
|
-
candidate = result.text
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
audit = check_policy(candidate, output_data: result.output_data)
|
|
76
|
-
if should_revise?(audit, revisions_used)
|
|
77
|
-
revisions_used += 1
|
|
78
|
-
append_revision_message(audit, attempt: revisions_used, terminal_tool_name: terminal&.tool_name)
|
|
79
|
-
emit("output_policy.revision", violation_count: audit.violations.length, attempt: revisions_used)
|
|
80
|
-
next
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
complete_with_output(candidate, output_data: result.output_data, audit: audit)
|
|
84
|
-
break
|
|
46
|
+
fence!
|
|
47
|
+
with_heartbeat do
|
|
48
|
+
emit("turn.started", status: status, model: model)
|
|
49
|
+
agent.effective_client.validate!(model: model)
|
|
50
|
+
execute
|
|
85
51
|
end
|
|
86
52
|
reload
|
|
87
53
|
self
|
|
54
|
+
rescue LostClaim
|
|
55
|
+
reload
|
|
88
56
|
rescue StandardError => error
|
|
57
|
+
# Background infrastructure failures must reach the job backend. The
|
|
58
|
+
# reconciler recovers the persisted phase rather than guessing here.
|
|
59
|
+
raise if background? && !error.is_a?(TurnKit::Error)
|
|
60
|
+
raise unless claimed
|
|
61
|
+
|
|
89
62
|
update!(status: "failed", error: { "class" => error.class.name, "message" => error.message }, completed_at: Clock.now)
|
|
90
63
|
emit("turn.failed", error: { "class" => error.class.name, "message" => error.message })
|
|
91
64
|
reload
|
|
92
65
|
self
|
|
93
66
|
end
|
|
94
67
|
|
|
68
|
+
def background? = !!@record["submitted_at"]
|
|
69
|
+
|
|
70
|
+
def perform_later(callback: nil)
|
|
71
|
+
Background.submit(self, callback: callback.respond_to?(:id) ? callback.id : callback)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def wait_for(*targets)
|
|
75
|
+
Background.wait(self, targets.flatten, transition: true)
|
|
76
|
+
reload
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def suspend!
|
|
80
|
+
update!(status: "waiting", claim_token: nil)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Revokes the local claim. An already-sent remote request cannot be
|
|
84
|
+
# recalled, but ExecutionStore fences every later write from that worker.
|
|
85
|
+
def cancel!(descendants: :retain, principal: nil)
|
|
86
|
+
raise ArgumentError, "descendants must be :retain or :cascade" unless %i[retain cascade].include?(descendants)
|
|
87
|
+
Authorization.authorize!(:cancel, principal: principal, turn: self, descendants: descendants)
|
|
88
|
+
@base_store.atomic(Background.root_conversation(@base_store, @record)) do
|
|
89
|
+
all = @base_store.list_turns(root_turn_id: root_turn_id)
|
|
90
|
+
rows = [@base_store.load_turn(id)]
|
|
91
|
+
if descendants == :cascade
|
|
92
|
+
descendant_ids = { id => true }
|
|
93
|
+
loop do
|
|
94
|
+
added = all.select { |row| row["parent_turn_id"] && descendant_ids[row["parent_turn_id"]] && !descendant_ids[row["id"]] }
|
|
95
|
+
break if added.empty?
|
|
96
|
+
added.each { |row| descendant_ids[row["id"]] = true }
|
|
97
|
+
end
|
|
98
|
+
rows.concat(all.select { |row| row["id"] != id && descendant_ids[row["id"]] })
|
|
99
|
+
end
|
|
100
|
+
rows.each do |row|
|
|
101
|
+
next if Background::TERMINAL.include?(row["status"])
|
|
102
|
+
executions = Reconciliation.interrupt_tool_executions(row, store: @base_store)
|
|
103
|
+
Reconciliation.repair_transcript(row, executions, store: @base_store)
|
|
104
|
+
attrs = { status: "cancelled", claim_token: nil, completed_at: Clock.now,
|
|
105
|
+
error: { "class" => "TurnKit::Cancelled", "message" => "cancelled by application" } }
|
|
106
|
+
terminal_row = @base_store.update_turn(row.fetch("id"), attrs)
|
|
107
|
+
Background.callback_after_terminal(@base_store, terminal_row) if row["submitted_at"]
|
|
108
|
+
end
|
|
109
|
+
rows.map { |row| row.fetch("conversation_id") }.uniq.each { |conversation_id| Background.wake(conversation_id, store: @base_store) } if background?
|
|
110
|
+
end
|
|
111
|
+
Background.enqueue if background?
|
|
112
|
+
reload
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def execution_budget(excluding: nil)
|
|
116
|
+
root = store.load_turn(root_turn_id)
|
|
117
|
+
limits = root.dig("options", "budget_limits") || agent.budget_limits
|
|
118
|
+
turns = store.list_turns(root_turn_id: root_turn_id)
|
|
119
|
+
executions = turns.flat_map { |row| store.list_tool_executions(turn_id: row.fetch("id")) }.reject { |row| row["id"] == excluding }
|
|
120
|
+
Budget.new(**limits.transform_keys(&:to_sym), root_started_at: root["submitted_at"] || root["started_at"] || Clock.now).seed!(turns: turns, tool_executions: executions)
|
|
121
|
+
end
|
|
122
|
+
|
|
95
123
|
def preview
|
|
96
124
|
model_request
|
|
97
125
|
end
|
|
@@ -112,6 +140,8 @@ module TurnKit
|
|
|
112
140
|
@record["output_data"]
|
|
113
141
|
end
|
|
114
142
|
|
|
143
|
+
def context = JSON.parse(JSON.generate(@record.dig("options", "context") || {}))
|
|
144
|
+
|
|
115
145
|
def policy_audit
|
|
116
146
|
options = @record["options"] || {}
|
|
117
147
|
options.dig("state", "policy_audit") || options["policy_audit"]
|
|
@@ -180,6 +210,7 @@ module TurnKit
|
|
|
180
210
|
if claimed
|
|
181
211
|
@record = claimed
|
|
182
212
|
@started_at = @record["started_at"]
|
|
213
|
+
fence!
|
|
183
214
|
emit("turn.started", status: status, model: model)
|
|
184
215
|
@budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
|
|
185
216
|
end
|
|
@@ -218,6 +249,7 @@ module TurnKit
|
|
|
218
249
|
if claimed
|
|
219
250
|
@record = claimed
|
|
220
251
|
@started_at = @record["started_at"]
|
|
252
|
+
fence!
|
|
221
253
|
emit("turn.started", status: status, model: model)
|
|
222
254
|
@budget = Budget.resume(store: store, root_turn_id: root_turn_id, limits: agent.budget_limits)
|
|
223
255
|
end
|
|
@@ -252,6 +284,87 @@ module TurnKit
|
|
|
252
284
|
end
|
|
253
285
|
|
|
254
286
|
private
|
|
287
|
+
def fence!
|
|
288
|
+
@store = ExecutionStore.new(store, turn_id: id, token: @record.fetch("claim_token"), conversation_id: Background.root_conversation(store, @record))
|
|
289
|
+
@conversation = Conversation.new(agent: agent, record: store.load_conversation(conversation.id), store: store,
|
|
290
|
+
model: conversation.model, subject: conversation.subject, metadata: conversation.metadata)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def execute
|
|
294
|
+
loop do
|
|
295
|
+
@budget = execution_budget
|
|
296
|
+
budget.check!(depth: depth)
|
|
297
|
+
state = @record.dig("options", "state") || {}
|
|
298
|
+
case state["phase"] || "model"
|
|
299
|
+
when "model"
|
|
300
|
+
count_iteration!
|
|
301
|
+
TurnKit::Compaction.maybe_compact!(self)
|
|
302
|
+
request = model_request
|
|
303
|
+
emit_model_requested("model.requested", request)
|
|
304
|
+
result = call_client(request)
|
|
305
|
+
cost = Cost.from_usage(result.usage, model: result.model || model)
|
|
306
|
+
store.atomic do
|
|
307
|
+
add_usage!(result.usage, cost: cost)
|
|
308
|
+
persist_assistant_message(result)
|
|
309
|
+
update_state!("phase" => result.tool_calls? ? "tools" : "output", "parts" => result.parts,
|
|
310
|
+
"candidate" => result.text, "output_data" => result.output_data, "terminal_tool_name" => nil)
|
|
311
|
+
end
|
|
312
|
+
emit_model_completed("model.completed", result, cost, model: model)
|
|
313
|
+
budget.add_cost!(cost.total)
|
|
314
|
+
when "tools"
|
|
315
|
+
runner = ToolRunner.new(self)
|
|
316
|
+
terminal = runner.dispatch(Result.new(parts: state.fetch("parts")).tool_calls)
|
|
317
|
+
if terminal == :waiting
|
|
318
|
+
suspend!
|
|
319
|
+
break
|
|
320
|
+
end
|
|
321
|
+
store.atomic do
|
|
322
|
+
if terminal
|
|
323
|
+
candidate = append_terminal_completion(runner, terminal)
|
|
324
|
+
update_state!("phase" => "output", "candidate" => candidate, "terminal_tool_name" => terminal.tool_name)
|
|
325
|
+
else
|
|
326
|
+
update_state!("phase" => "model", "parts" => nil)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
when "output"
|
|
330
|
+
candidate = state.fetch("candidate")
|
|
331
|
+
audit = check_policy(candidate, output_data: state["output_data"])
|
|
332
|
+
revisions_used = state["revisions_used"].to_i
|
|
333
|
+
if should_revise?(audit, revisions_used)
|
|
334
|
+
store.atomic do
|
|
335
|
+
append_revision_message(audit, attempt: revisions_used + 1, terminal_tool_name: state["terminal_tool_name"])
|
|
336
|
+
update_state!("phase" => "model", "parts" => nil, "revisions_used" => revisions_used + 1)
|
|
337
|
+
end
|
|
338
|
+
emit("output_policy.revision", violation_count: audit.violations.length, attempt: revisions_used + 1)
|
|
339
|
+
else
|
|
340
|
+
complete_with_output(candidate, output_data: state["output_data"], audit: audit)
|
|
341
|
+
break
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def with_heartbeat
|
|
348
|
+
mutex, wake = Mutex.new, ConditionVariable.new
|
|
349
|
+
stopped = false
|
|
350
|
+
heartbeat = Thread.new do
|
|
351
|
+
loop do
|
|
352
|
+
break if mutex.synchronize {
|
|
353
|
+
wake.wait(mutex, (TurnKit.timeout || 300) / 3.0) unless stopped
|
|
354
|
+
stopped
|
|
355
|
+
}
|
|
356
|
+
heartbeat!
|
|
357
|
+
end
|
|
358
|
+
rescue LostClaim
|
|
359
|
+
# The executing thread observes the same revocation on its next write.
|
|
360
|
+
end
|
|
361
|
+
yield
|
|
362
|
+
ensure
|
|
363
|
+
# Never interrupt a database write/connection initialization mid-flight.
|
|
364
|
+
mutex.synchronize { stopped = true; wake.signal }
|
|
365
|
+
heartbeat&.value
|
|
366
|
+
end
|
|
367
|
+
|
|
255
368
|
def model_request
|
|
256
369
|
prompt = SystemPrompt.new(agent: agent, turn: self, conversation: conversation, mode: prompt_mode || agent.effective_prompt_mode(turn: self))
|
|
257
370
|
instructions, dynamic_instructions = case agent.system_prompt
|
|
@@ -265,7 +378,7 @@ module TurnKit
|
|
|
265
378
|
ModelRequest.new(
|
|
266
379
|
model: model,
|
|
267
380
|
messages: llm_messages,
|
|
268
|
-
tools: agent.effective_tools,
|
|
381
|
+
tools: agent.effective_tools(turn: self),
|
|
269
382
|
instructions: instructions,
|
|
270
383
|
dynamic_instructions: dynamic_instructions,
|
|
271
384
|
thinking: thinking,
|
|
@@ -291,11 +404,11 @@ module TurnKit
|
|
|
291
404
|
end
|
|
292
405
|
|
|
293
406
|
def call_image_client(client, request)
|
|
294
|
-
client.paint(**request, on_event: ->(event) { emit_event(event) })
|
|
407
|
+
with_heartbeat { client.paint(**request, on_event: ->(event) { emit_event(event) }) }
|
|
295
408
|
end
|
|
296
409
|
|
|
297
410
|
def call_media_client(client, request)
|
|
298
|
-
client.view_media(**request, on_event: ->(event) { emit_event(event) })
|
|
411
|
+
with_heartbeat { client.view_media(**request, on_event: ->(event) { emit_event(event) }) }
|
|
299
412
|
end
|
|
300
413
|
|
|
301
414
|
def llm_messages
|
|
@@ -392,8 +505,11 @@ module TurnKit
|
|
|
392
505
|
else
|
|
393
506
|
attrs[:status] = "completed"
|
|
394
507
|
end
|
|
395
|
-
|
|
396
|
-
|
|
508
|
+
store.atomic(Background.root_conversation(store, @record)) do
|
|
509
|
+
update_state!("policy_audit" => audit.to_h) if audit
|
|
510
|
+
update!(attrs)
|
|
511
|
+
end
|
|
512
|
+
emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length) if audit
|
|
397
513
|
|
|
398
514
|
if failed?
|
|
399
515
|
emit("turn.failed", error: @record["error"])
|
|
@@ -410,11 +526,6 @@ module TurnKit
|
|
|
410
526
|
TurnKit.check_output_policy(output, constraints: constraints, context: { turn: self, output_text: text, output_data: output_data })
|
|
411
527
|
end
|
|
412
528
|
|
|
413
|
-
def persist_policy_audit(audit)
|
|
414
|
-
update_state!("policy_audit" => audit.to_h)
|
|
415
|
-
emit("output_policy.completed", clean: audit.clean?, violation_count: audit.violations.length)
|
|
416
|
-
end
|
|
417
|
-
|
|
418
529
|
def should_revise?(audit, revisions_used)
|
|
419
530
|
audit && !audit.clean? && revisions_used < agent.output_retries
|
|
420
531
|
end
|
|
@@ -462,8 +573,11 @@ module TurnKit
|
|
|
462
573
|
end
|
|
463
574
|
|
|
464
575
|
def count_iteration!
|
|
465
|
-
|
|
466
|
-
|
|
576
|
+
store.atomic do
|
|
577
|
+
@budget = execution_budget
|
|
578
|
+
budget.count_iteration!
|
|
579
|
+
update_state!("iterations" => Turn.iterations_for(@record) + 1)
|
|
580
|
+
end
|
|
467
581
|
end
|
|
468
582
|
|
|
469
583
|
# Runtime state lives under options["state"]; the rest of options is
|
|
@@ -475,7 +589,7 @@ module TurnKit
|
|
|
475
589
|
end
|
|
476
590
|
|
|
477
591
|
def heartbeat!
|
|
478
|
-
|
|
592
|
+
store.update_turn(id, heartbeat_at: Clock.now)
|
|
479
593
|
end
|
|
480
594
|
|
|
481
595
|
# Claims a pending turn for a standalone media call. Returns the claimed
|
|
@@ -486,7 +600,7 @@ module TurnKit
|
|
|
486
600
|
def claim_standalone!(action)
|
|
487
601
|
case status
|
|
488
602
|
when "pending"
|
|
489
|
-
claimed =
|
|
603
|
+
claimed = Background.claim(store, @record)
|
|
490
604
|
raise Error, "turn is already running" unless claimed
|
|
491
605
|
|
|
492
606
|
claimed
|
|
@@ -516,7 +630,21 @@ module TurnKit
|
|
|
516
630
|
end
|
|
517
631
|
|
|
518
632
|
def update!(attributes)
|
|
519
|
-
|
|
633
|
+
terminal = Background::TERMINAL.include?(attributes[:status])
|
|
634
|
+
store.atomic(Background.root_conversation(store, @record)) do
|
|
635
|
+
if terminal
|
|
636
|
+
if attributes[:status] == "failed" && background?
|
|
637
|
+
executions = Reconciliation.interrupt_tool_executions(@record, store: store)
|
|
638
|
+
Reconciliation.repair_transcript(@record, executions, store: store)
|
|
639
|
+
end
|
|
640
|
+
attributes = attributes.merge(claim_token: nil)
|
|
641
|
+
end
|
|
642
|
+
@record = store.update_turn(id, attributes)
|
|
643
|
+
# The terminal write clears this execution's claim token, so durable
|
|
644
|
+
# callback work must use the unfenced store after that write.
|
|
645
|
+
Background.callback_after_terminal(@base_store, @record) if terminal && background?
|
|
646
|
+
Background.wake(conversation.id, store: @base_store) if terminal && background?
|
|
647
|
+
end
|
|
520
648
|
@started_at = @record["started_at"]
|
|
521
649
|
@model = @record["model"] || agent.effective_model
|
|
522
650
|
@record
|
|
@@ -535,5 +663,8 @@ module TurnKit
|
|
|
535
663
|
@turn = turn
|
|
536
664
|
@execution = execution
|
|
537
665
|
end
|
|
666
|
+
|
|
667
|
+
def idempotency_key = "turnkit:tool:#{execution.id}"
|
|
668
|
+
def principal = turn.store.load_turn(turn.id).dig("options", "principal")
|
|
538
669
|
end
|
|
539
670
|
end
|
data/lib/turnkit/version.rb
CHANGED
data/lib/turnkit.rb
CHANGED
|
@@ -16,6 +16,7 @@ require_relative "turnkit/budget"
|
|
|
16
16
|
require_relative "turnkit/event"
|
|
17
17
|
require_relative "turnkit/model_request"
|
|
18
18
|
require_relative "turnkit/schema_check"
|
|
19
|
+
require_relative "turnkit/authorization"
|
|
19
20
|
require_relative "turnkit/agent"
|
|
20
21
|
require_relative "turnkit/client"
|
|
21
22
|
require_relative "turnkit/conversation"
|
|
@@ -50,6 +51,10 @@ require_relative "turnkit/run"
|
|
|
50
51
|
require_relative "turnkit/adapters/codex"
|
|
51
52
|
require_relative "turnkit/adapters/ruby_llm"
|
|
52
53
|
require_relative "turnkit/active_record_store"
|
|
54
|
+
require_relative "turnkit/execution_store"
|
|
55
|
+
require_relative "turnkit/background"
|
|
56
|
+
require_relative "turnkit/coordination_tools"
|
|
57
|
+
require_relative "turnkit/specialists"
|
|
53
58
|
|
|
54
59
|
module TurnKit
|
|
55
60
|
class << self
|
|
@@ -63,6 +68,29 @@ module TurnKit
|
|
|
63
68
|
attr_accessor :prompt_sections, :prompt_behavior, :available_skills
|
|
64
69
|
attr_accessor :prompt_data_max_chars, :context_contributors
|
|
65
70
|
attr_accessor :on_event
|
|
71
|
+
attr_accessor :job_dispatcher
|
|
72
|
+
attr_accessor :authorization_policy, :maintenance_batch_size
|
|
73
|
+
attr_reader :agents
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@agents = {}
|
|
77
|
+
|
|
78
|
+
def self.register(agent)
|
|
79
|
+
@agents[agent.name] = agent
|
|
80
|
+
agent.sub_agents.each { |child| register(child) }
|
|
81
|
+
agent
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.resolve_agent(name)
|
|
85
|
+
@agents.fetch(name.to_s) { raise ConfigError, "register agent #{name.inspect} in every worker at boot" }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.load_turn(id)
|
|
89
|
+
Background.load_turn(id)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.load_conversation(id)
|
|
93
|
+
Background.load_conversation(id)
|
|
66
94
|
end
|
|
67
95
|
|
|
68
96
|
self.default_model = "claude-sonnet-4-5"
|
|
@@ -84,6 +112,8 @@ module TurnKit
|
|
|
84
112
|
self.on_event = nil
|
|
85
113
|
self.output_policy_model = nil
|
|
86
114
|
self.output_policy_thinking = { effort: :low }
|
|
115
|
+
self.authorization_policy = nil
|
|
116
|
+
self.maintenance_batch_size = 100
|
|
87
117
|
|
|
88
118
|
def self.configure
|
|
89
119
|
yield self
|