turnkit 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +198 -5
- data/UPGRADE.md +57 -0
- data/lib/generators/turnkit/install/templates/create_turnkit_tables.rb +30 -0
- data/lib/generators/turnkit/install/templates/delivery.rb +7 -0
- data/lib/generators/turnkit/install/templates/initializer.rb +5 -0
- data/lib/generators/turnkit/install/templates/wait.rb +7 -0
- data/lib/generators/turnkit/install_generator.rb +2 -0
- data/lib/generators/turnkit/upgrade/templates/add_turnkit_durable_orchestration.rb +36 -0
- data/lib/generators/turnkit/upgrade_generator.rb +34 -0
- data/lib/turnkit/active_record_store.rb +124 -14
- data/lib/turnkit/adapters/ruby_llm.rb +107 -21
- data/lib/turnkit/agent.rb +26 -20
- data/lib/turnkit/authorization.rb +17 -0
- data/lib/turnkit/background.rb +281 -0
- data/lib/turnkit/budget.rb +4 -3
- data/lib/turnkit/client.rb +3 -1
- data/lib/turnkit/conversation.rb +54 -1
- data/lib/turnkit/coordination_tools.rb +67 -0
- data/lib/turnkit/cost.rb +7 -7
- data/lib/turnkit/error.rb +3 -0
- data/lib/turnkit/execution_store.rb +30 -0
- data/lib/turnkit/id.rb +1 -0
- data/lib/turnkit/image_tool.rb +10 -0
- data/lib/turnkit/job.rb +21 -0
- data/lib/turnkit/memory_store.rb +113 -20
- data/lib/turnkit/message_projection.rb +4 -1
- data/lib/turnkit/reconciliation.rb +13 -10
- data/lib/turnkit/record.rb +36 -3
- data/lib/turnkit/run.rb +28 -0
- data/lib/turnkit/skill.rb +5 -4
- data/lib/turnkit/specialists.rb +254 -0
- data/lib/turnkit/store.rb +38 -9
- data/lib/turnkit/sub_agent_tool.rb +23 -7
- data/lib/turnkit/system_prompt.rb +7 -7
- data/lib/turnkit/tool.rb +11 -0
- data/lib/turnkit/tool_runner.rb +109 -45
- data/lib/turnkit/turn.rb +238 -61
- data/lib/turnkit/turn_controls.rb +136 -0
- data/lib/turnkit/version.rb +1 -1
- data/lib/turnkit.rb +31 -0
- metadata +16 -5
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
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
21
|
-
sub_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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/turnkit/tool_runner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
turn.
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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" =>
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
126
|
-
|
|
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)
|