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/memory_store.rb
CHANGED
|
@@ -1,16 +1,41 @@
|
|
|
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 =
|
|
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
|
|
12
18
|
end
|
|
13
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
|
|
36
|
+
end
|
|
37
|
+
def atomic_graph(&block) = atomic(nil, &block)
|
|
38
|
+
|
|
14
39
|
def create_conversation(attributes)
|
|
15
40
|
record = Record.conversation(attributes)
|
|
16
41
|
|
|
@@ -33,11 +58,13 @@ module TurnKit
|
|
|
33
58
|
end
|
|
34
59
|
|
|
35
60
|
def append_message(attributes)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
attrs = stringify(attributes)
|
|
63
|
+
attrs["sequence"] ||= next_message_sequence(attrs.fetch("conversation_id"))
|
|
64
|
+
message = Record.message(attrs)
|
|
65
|
+
@messages[message.fetch("id")] = message
|
|
66
|
+
duplicate(message)
|
|
67
|
+
end
|
|
41
68
|
end
|
|
42
69
|
|
|
43
70
|
def list_messages(conversation_id, through_sequence: nil, turn_id: nil)
|
|
@@ -89,6 +116,86 @@ module TurnKit
|
|
|
89
116
|
end
|
|
90
117
|
end
|
|
91
118
|
|
|
119
|
+
def list_submitted_turns(limit: nil)
|
|
120
|
+
@mutex.synchronize do
|
|
121
|
+
rows = @turns.values.select { |turn| turn["submitted_at"] }.sort_by { |turn| [ turn["created_at"].to_f, turn["id"] ] }
|
|
122
|
+
rows = rows.first(limit) if limit
|
|
123
|
+
rows.map { |turn| duplicate(turn) }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def list_actionable_turns(limit:)
|
|
128
|
+
@mutex.synchronize do
|
|
129
|
+
@turns.values.select { |turn| turn["submitted_at"] && %w[pending waiting running].include?(turn["status"]) }
|
|
130
|
+
.sort_by { |turn| [ turn["updated_at"].to_f, turn["id"] ] }.first(limit).map { |turn| duplicate(turn) }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def list_stale_inline_turns(before:, limit:)
|
|
135
|
+
@mutex.synchronize do
|
|
136
|
+
@turns.values.select { |row| !row["submitted_at"] && %w[pending running].include?(row["status"]) &&
|
|
137
|
+
(row["heartbeat_at"] || row["started_at"] || row["created_at"]) < before }
|
|
138
|
+
.sort_by { |row| [row["updated_at"].to_f, row["id"]] }.first(limit).map { |row| duplicate(row) }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def create_delivery(attributes)
|
|
143
|
+
record = Record.delivery(attributes)
|
|
144
|
+
@mutex.synchronize do
|
|
145
|
+
existing_id = @delivery_keys[record.fetch("key")]
|
|
146
|
+
if existing_id
|
|
147
|
+
existing = @deliveries.fetch(existing_id)
|
|
148
|
+
Record.assert_delivery_retry!(existing, record)
|
|
149
|
+
return duplicate(existing)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
@deliveries[record.fetch("id")] = record
|
|
153
|
+
@delivery_keys[record.fetch("key")] = record.fetch("id")
|
|
154
|
+
duplicate(record)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def load_delivery(id)
|
|
159
|
+
@mutex.synchronize { duplicate(@deliveries.fetch(id)) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def update_delivery(id, attributes)
|
|
163
|
+
attrs = Record.delivery_update(attributes)
|
|
164
|
+
@mutex.synchronize do
|
|
165
|
+
@deliveries.fetch(id).merge!(attrs)
|
|
166
|
+
duplicate(@deliveries.fetch(id))
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def list_deliveries(source_conversation_id: nil, destination_conversation_id: nil, pending: false, limit: nil)
|
|
171
|
+
@mutex.synchronize do
|
|
172
|
+
rows = @deliveries.values
|
|
173
|
+
rows = rows.select { |row| row["source_conversation_id"] == source_conversation_id } if source_conversation_id
|
|
174
|
+
rows = rows.select { |row| row["destination_conversation_id"] == destination_conversation_id } if destination_conversation_id
|
|
175
|
+
rows = rows.select { |row| row["delivered_at"].nil? } if pending
|
|
176
|
+
rows = rows.sort_by { |row| [ row["created_at"].to_f, row["id"] ] }
|
|
177
|
+
rows = rows.first(limit) if limit
|
|
178
|
+
rows.map { |row| duplicate(row) }
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def create_wait(turn_id:, target_turn_id:)
|
|
183
|
+
@mutex.synchronize do
|
|
184
|
+
wait = { "turn_id" => turn_id, "target_turn_id" => target_turn_id }
|
|
185
|
+
@waits[[ turn_id, target_turn_id ]] ||= wait
|
|
186
|
+
duplicate(@waits.fetch([ turn_id, target_turn_id ]))
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def list_waits(turn_id: nil, target_turn_id: nil)
|
|
191
|
+
@mutex.synchronize do
|
|
192
|
+
rows = @waits.values
|
|
193
|
+
rows = rows.select { |row| row["turn_id"] == turn_id } if turn_id
|
|
194
|
+
rows = rows.select { |row| row["target_turn_id"] == target_turn_id } if target_turn_id
|
|
195
|
+
rows.map { |row| duplicate(row) }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
92
199
|
def create_tool_execution(attributes)
|
|
93
200
|
record = Record.tool_execution(attributes)
|
|
94
201
|
|
|
@@ -120,17 +227,6 @@ module TurnKit
|
|
|
120
227
|
end
|
|
121
228
|
end
|
|
122
229
|
|
|
123
|
-
def reconcile_stale_turns(before:)
|
|
124
|
-
@mutex.synchronize do
|
|
125
|
-
@turns.values.filter_map do |turn|
|
|
126
|
-
next unless %w[pending running].include?(turn["status"]) && stale_anchor(turn) && stale_anchor(turn) < before
|
|
127
|
-
|
|
128
|
-
turn.merge!("status" => "stale", "completed_at" => Clock.now, "updated_at" => Clock.now)
|
|
129
|
-
duplicate(turn)
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
|
|
134
230
|
private
|
|
135
231
|
def stringify(hash)
|
|
136
232
|
hash.transform_keys(&:to_s)
|
|
@@ -140,8 +236,5 @@ module TurnKit
|
|
|
140
236
|
Marshal.load(Marshal.dump(value))
|
|
141
237
|
end
|
|
142
238
|
|
|
143
|
-
def stale_anchor(turn)
|
|
144
|
-
turn["heartbeat_at"] || turn["started_at"] || turn["created_at"]
|
|
145
|
-
end
|
|
146
239
|
end
|
|
147
240
|
end
|
|
@@ -33,7 +33,10 @@ module TurnKit
|
|
|
33
33
|
{ role: :assistant, content: [ CONTEXT_SUMMARY_PREFIX, message.text ].reject(&:empty?).join("\n\n") }
|
|
34
34
|
]
|
|
35
35
|
else
|
|
36
|
-
|
|
36
|
+
projected = to_h
|
|
37
|
+
provider_parts = message.content.select { |part| part["type"] == "provider" }
|
|
38
|
+
projected[:provider_parts] = provider_parts if provider_parts.any?
|
|
39
|
+
[ projected ]
|
|
37
40
|
end
|
|
38
41
|
end
|
|
39
42
|
|
|
@@ -4,8 +4,8 @@ module TurnKit
|
|
|
4
4
|
# Reconciles turns abandoned by a dead worker: atomically marks them stale,
|
|
5
5
|
# marks their unfinished tool executions interrupted, and appends synthetic
|
|
6
6
|
# error tool results so the persisted transcript stays structurally complete
|
|
7
|
-
# for continuation.
|
|
8
|
-
#
|
|
7
|
+
# for continuation. Unknown effects are not replayed; Background separately
|
|
8
|
+
# retries tools whose integrations explicitly declare replay safety.
|
|
9
9
|
module Reconciliation
|
|
10
10
|
INTERRUPTED_MESSAGE = "Tool execution was interrupted before a result was recorded. " \
|
|
11
11
|
"It is unknown whether the operation ran; do not assume it did or did not."
|
|
@@ -13,15 +13,17 @@ module TurnKit
|
|
|
13
13
|
module_function
|
|
14
14
|
|
|
15
15
|
def reconcile!(before:)
|
|
16
|
-
TurnKit.store.reconcile_stale_turns(before: before)
|
|
16
|
+
reconciled = TurnKit.store.reconcile_stale_turns(before: before)
|
|
17
|
+
reconciled.each do |turn|
|
|
17
18
|
emit("turn.stale", turn)
|
|
18
19
|
executions = interrupt_tool_executions(turn)
|
|
19
20
|
repair_transcript(turn, executions)
|
|
20
21
|
end
|
|
22
|
+
Background.reconcile(before: before) if TurnKit.store.list_actionable_turns(limit: 1).any?
|
|
23
|
+
reconciled
|
|
21
24
|
end
|
|
22
25
|
|
|
23
|
-
def interrupt_tool_executions(turn)
|
|
24
|
-
store = TurnKit.store
|
|
26
|
+
def interrupt_tool_executions(turn, store: TurnKit.store)
|
|
25
27
|
store.list_tool_executions(turn_id: turn.fetch("id")).map do |execution|
|
|
26
28
|
next execution unless %w[pending running].include?(execution.fetch("status"))
|
|
27
29
|
|
|
@@ -39,11 +41,10 @@ module TurnKit
|
|
|
39
41
|
end
|
|
40
42
|
end
|
|
41
43
|
|
|
42
|
-
def repair_transcript(turn, executions)
|
|
43
|
-
store = TurnKit.store
|
|
44
|
+
def repair_transcript(turn, executions, store: TurnKit.store)
|
|
44
45
|
messages = store.list_messages(turn.fetch("conversation_id"))
|
|
45
46
|
resolved = messages
|
|
46
|
-
.select { |message| message["kind"] == "tool_result" }
|
|
47
|
+
.select { |message| message["turn_id"] == turn.fetch("id") && message["kind"] == "tool_result" }
|
|
47
48
|
.flat_map { |message| message["content"].map { |part| part["tool_call_id"] } }
|
|
48
49
|
|
|
49
50
|
messages
|
|
@@ -52,14 +53,16 @@ module TurnKit
|
|
|
52
53
|
.reject { |part| resolved.include?(part["id"]) }
|
|
53
54
|
.each do |part|
|
|
54
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 }
|
|
55
58
|
message = store.append_message(
|
|
56
59
|
"conversation_id" => turn.fetch("conversation_id"),
|
|
57
60
|
"turn_id" => turn.fetch("id"),
|
|
58
61
|
"role" => "tool",
|
|
59
62
|
"kind" => "tool_result",
|
|
60
|
-
"content" => [ { "type" => "tool_result", "tool_call_id" => part["id"], "text" =>
|
|
63
|
+
"content" => [ { "type" => "tool_result", "tool_call_id" => part["id"], "text" => payload.to_json, "error" => !known || execution["status"] == "failed" } ],
|
|
61
64
|
"tool_execution_id" => execution&.fetch("id"),
|
|
62
|
-
"metadata" => { "tool_name" => part["name"], "interrupted" =>
|
|
65
|
+
"metadata" => { "tool_name" => part["name"], "interrupted" => !known }
|
|
63
66
|
)
|
|
64
67
|
emit("message.created", turn, message_id: message.fetch("id"), role: "tool", kind: "tool_result")
|
|
65
68
|
end
|
data/lib/turnkit/record.rb
CHANGED
|
@@ -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
|
|
5
|
+
TURN_STATUSES = %w[pending waiting paused running completed failed cancelled stale].freeze
|
|
6
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,34 @@ 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
|
+
|
|
55
|
+
def pause!(**options)
|
|
56
|
+
turn.pause!(**options)
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resume!(**options)
|
|
61
|
+
turn.resume!(**options)
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def steer!(text, **options) = turn.steer!(text, **options)
|
|
66
|
+
def control_state(**options) = turn.control_state(**options)
|
|
67
|
+
|
|
40
68
|
def reload
|
|
41
69
|
turn.reload
|
|
42
70
|
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?
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module TurnKit
|
|
10
|
+
# Small, composable factories for common specialist agents. The returned
|
|
11
|
+
# objects are ordinary Agent and Tool instances and may be further configured
|
|
12
|
+
# or subclassed by applications.
|
|
13
|
+
module Specialists
|
|
14
|
+
ORACLE_INSTRUCTIONS = <<~TEXT.strip
|
|
15
|
+
Act as an expert advisor. Inspect the supplied read-only sources before
|
|
16
|
+
drawing conclusions. Complete the requested analysis, distinguish facts
|
|
17
|
+
from recommendations, state material uncertainty, and return the full
|
|
18
|
+
result. Never claim to have changed application state.
|
|
19
|
+
TEXT
|
|
20
|
+
|
|
21
|
+
LIBRARIAN_INSTRUCTIONS = <<~TEXT.strip
|
|
22
|
+
Research the configured external GitHub repository using the repository
|
|
23
|
+
tool. Read the relevant files and history, not merely repository titles.
|
|
24
|
+
Return a complete answer with source URLs for factual claims and identify
|
|
25
|
+
the ref or comparison used. Do not imply that repository state was changed.
|
|
26
|
+
TEXT
|
|
27
|
+
|
|
28
|
+
PAINTER_INSTRUCTIONS = <<~TEXT.strip
|
|
29
|
+
Create or edit the requested image with the image tool. Translate the task
|
|
30
|
+
into a precise visual prompt, pass all relevant reference images and mask,
|
|
31
|
+
and return the generated image artifact. Generation is permitted only when
|
|
32
|
+
the application authorization gate allows the tool call.
|
|
33
|
+
TEXT
|
|
34
|
+
|
|
35
|
+
class ReadOnlyTool < Tool
|
|
36
|
+
recovery :replay_safe
|
|
37
|
+
def self.read_only? = true
|
|
38
|
+
def read_only? = true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class ReadFile < ReadOnlyTool
|
|
42
|
+
tool_name "read_file"
|
|
43
|
+
description "Read a UTF-8 text file beneath the configured root."
|
|
44
|
+
parameter :path, :string, required: true, description: "Path relative to the configured root."
|
|
45
|
+
|
|
46
|
+
def initialize(root:, max_bytes: 100_000)
|
|
47
|
+
@root = Pathname(root).expand_path.realpath
|
|
48
|
+
@max_bytes = Integer(max_bytes)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(path:, context:)
|
|
52
|
+
target = confined_path(path)
|
|
53
|
+
raise ToolError, "not a file: #{path}" unless target.file?
|
|
54
|
+
raise ToolError, "file exceeds #{@max_bytes} bytes" if target.size > @max_bytes
|
|
55
|
+
|
|
56
|
+
{ "path" => target.relative_path_from(@root).to_s, "content" => target.binread.force_encoding(Encoding::UTF_8) }
|
|
57
|
+
rescue Errno::ENOENT, Errno::EACCES, ArgumentError => error
|
|
58
|
+
raise ToolError, "cannot read #{path}: #{error.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def confined_path(path)
|
|
64
|
+
candidate = @root.join(path.to_s).realpath
|
|
65
|
+
prefix = @root.to_s + File::SEPARATOR
|
|
66
|
+
raise ToolError, "path is outside configured root" unless candidate == @root || candidate.to_s.start_with?(prefix)
|
|
67
|
+
|
|
68
|
+
candidate
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class GitHubRepository < ReadOnlyTool
|
|
73
|
+
tool_name "github_repository_read"
|
|
74
|
+
description "Read files, repository metadata, commits, diffs, and issues from one configured GitHub repository."
|
|
75
|
+
parameter :operation, :enum, required: true, enum: %w[repository file history diff issues issue comments]
|
|
76
|
+
parameter :path, :string, description: "Repository-relative file path (file operation)."
|
|
77
|
+
parameter :ref, :string, description: "Branch, tag, or commit for file/history operations."
|
|
78
|
+
parameter :base, :string, description: "Base ref for a diff."
|
|
79
|
+
parameter :head, :string, description: "Head ref for a diff."
|
|
80
|
+
parameter :number, :integer, description: "Issue number."
|
|
81
|
+
parameter :state, :enum, enum: %w[open closed all], default: "open"
|
|
82
|
+
parameter :page, :integer, default: 1
|
|
83
|
+
|
|
84
|
+
API_HOST = "api.github.com"
|
|
85
|
+
|
|
86
|
+
def initialize(repository:, client: nil, token: ENV["GITHUB_TOKEN"])
|
|
87
|
+
@repository = normalize_repository(repository)
|
|
88
|
+
@client = client || method(:http_get)
|
|
89
|
+
@token = token.to_s
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def call(operation:, path: nil, ref: nil, base: nil, head: nil, number: nil, state: "open", page: 1, context:)
|
|
93
|
+
endpoint, query = endpoint_for(operation, path: path, ref: ref, base: base, head: head,
|
|
94
|
+
number: number, state: state, page: page)
|
|
95
|
+
uri = URI::HTTPS.build(host: API_HOST, path: "/repos/#{@repository}#{endpoint.empty? ? '' : '/' + endpoint}", query: URI.encode_www_form(query))
|
|
96
|
+
response = @client.call(uri: uri, headers: headers)
|
|
97
|
+
status, body = unpack_response(response)
|
|
98
|
+
raise ToolError, "GitHub API returned HTTP #{status}" unless status.between?(200, 299)
|
|
99
|
+
|
|
100
|
+
data = JSON.parse(body)
|
|
101
|
+
if operation == "file" && data.is_a?(Hash) && data["encoding"] == "base64"
|
|
102
|
+
data = data.merge("content" => Base64.decode64(data.fetch("content")))
|
|
103
|
+
end
|
|
104
|
+
{ "repository" => @repository, "operation" => operation, "source" => source_url(operation, data, uri), "data" => data }
|
|
105
|
+
rescue JSON::ParserError => error
|
|
106
|
+
raise ToolError, "invalid GitHub API response: #{error.message}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def normalize_repository(value)
|
|
112
|
+
repository = value.to_s
|
|
113
|
+
raise ArgumentError, "repository must be owner/name" unless repository.match?(%r{\A[A-Za-z0-9][A-Za-z0-9_-]*/[A-Za-z0-9_.-]+\z}) && !%w[. ..].include?(repository.split('/').last)
|
|
114
|
+
|
|
115
|
+
repository
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def endpoint_for(operation, path:, ref:, base:, head:, number:, state:, page:)
|
|
119
|
+
case operation
|
|
120
|
+
when "repository" then [ "", {} ]
|
|
121
|
+
when "file"
|
|
122
|
+
raise ToolValidationError, "path is required for file" if path.to_s.empty?
|
|
123
|
+
raise ToolValidationError, "invalid repository path" if path.to_s.split("/").include?("..") || path.to_s.start_with?("/")
|
|
124
|
+
[ "contents/#{path.to_s.split('/').map { |part| escape_segment(part) }.join('/')}", compact_query(ref: ref) ]
|
|
125
|
+
when "history" then [ "commits", compact_query(sha: ref, path: path, page: page, per_page: 30) ]
|
|
126
|
+
when "diff"
|
|
127
|
+
raise ToolValidationError, "base and head are required for diff" if base.to_s.empty? || head.to_s.empty?
|
|
128
|
+
[ "compare/#{escape_segment(base)}...#{escape_segment(head)}", {} ]
|
|
129
|
+
when "issues" then [ "issues", compact_query(state: state, page: page, per_page: 30) ]
|
|
130
|
+
when "issue"
|
|
131
|
+
raise ToolValidationError, "number is required for issue" unless number
|
|
132
|
+
[ "issues/#{Integer(number)}", {} ]
|
|
133
|
+
when "comments"
|
|
134
|
+
raise ToolValidationError, "positive issue number is required" unless number && number.positive?
|
|
135
|
+
[ "issues/#{number}/comments", compact_query(page: page, per_page: 30) ]
|
|
136
|
+
else raise ToolValidationError, "unknown operation: #{operation}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def escape_segment(value) = URI.encode_www_form_component(value.to_s).gsub("+", "%20")
|
|
141
|
+
def compact_query(**values) = values.reject { |_key, value| value.nil? || value.to_s.empty? }
|
|
142
|
+
|
|
143
|
+
def headers
|
|
144
|
+
result = { "Accept" => "application/vnd.github+json", "User-Agent" => "TurnKit" }
|
|
145
|
+
result["Authorization"] = "Bearer #{@token}" unless @token.empty?
|
|
146
|
+
result
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def http_get(uri:, headers:)
|
|
150
|
+
raise ToolError, "unsafe GitHub API endpoint" unless uri.is_a?(URI::HTTPS) && uri.host == API_HOST && uri.port == 443
|
|
151
|
+
|
|
152
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 45) { |http| http.get(uri.request_uri, headers) }
|
|
153
|
+
# Redirects are deliberately not followed, preventing credentials from
|
|
154
|
+
# being forwarded to a different endpoint.
|
|
155
|
+
[ response.code.to_i, response.body.to_s ]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def unpack_response(response)
|
|
159
|
+
return [ Integer(response[0]), response[1].to_s ] if response.is_a?(Array) && response.length == 2
|
|
160
|
+
return [ Integer(response.code), response.body.to_s ] if response.respond_to?(:code) && response.respond_to?(:body)
|
|
161
|
+
|
|
162
|
+
raise ToolError, "GitHub client must return [status, body] or an HTTP response"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def source_url(operation, data, uri)
|
|
166
|
+
return data["html_url"] if data.is_a?(Hash) && data["html_url"].to_s.start_with?("https://github.com/")
|
|
167
|
+
return data.first["html_url"] if data.is_a?(Array) && data.first.is_a?(Hash) && data.first["html_url"].to_s.start_with?("https://github.com/")
|
|
168
|
+
|
|
169
|
+
uri.to_s
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
class PaintImage < ImageTool
|
|
174
|
+
tool_name "paint_image"
|
|
175
|
+
description "Generate or edit an image after application authorization."
|
|
176
|
+
parameter :prompt, :string, required: true
|
|
177
|
+
parameter :reference_images, :array, default: [], items: :string
|
|
178
|
+
parameter :mask, :string
|
|
179
|
+
terminal! { |result| JSON.generate(result) }
|
|
180
|
+
|
|
181
|
+
def initialize(model:, authorization:, provider: nil, size: nil, params: {}, max_reference_images: nil)
|
|
182
|
+
raise ArgumentError, "authorization must be callable" unless authorization.respond_to?(:call)
|
|
183
|
+
@image_model, @authorization, @provider, @size = model, authorization, provider, size
|
|
184
|
+
@params, @max_reference_images = params, max_reference_images
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def call(prompt:, reference_images: [], mask: nil, context:)
|
|
188
|
+
references = Array(reference_images)
|
|
189
|
+
if @max_reference_images && references.length > Integer(@max_reference_images)
|
|
190
|
+
raise ToolValidationError, "reference_images exceeds configured limit of #{@max_reference_images}"
|
|
191
|
+
end
|
|
192
|
+
request = { prompt: prompt, model: @image_model, provider: @provider, size: @size,
|
|
193
|
+
input_images: references, mask: mask, params: @params }
|
|
194
|
+
raise ToolError, "image generation was not authorized" unless @authorization.call(request.dup, context: context) == true
|
|
195
|
+
|
|
196
|
+
image = context.turn.paint(request.delete(:prompt), **request, metadata: { "specialist" => "painter" })
|
|
197
|
+
message = context.turn.conversation.messages.reverse.find(&:image?)
|
|
198
|
+
{ "conversation_id" => context.turn.conversation.id, "image_message_id" => message.id,
|
|
199
|
+
"image" => image.to_h.reject { |key, _| key == "data" } }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
module_function
|
|
204
|
+
|
|
205
|
+
def oracle(model:, tools: nil, root: Dir.pwd, client: nil, instructions: nil, **agent_options)
|
|
206
|
+
require_model!(model)
|
|
207
|
+
configured = tools.nil? ? [ ReadFile.new(root: root) ] : Array(tools)
|
|
208
|
+
validate_read_only!(configured + skill_tools(agent_options))
|
|
209
|
+
raise ArgumentError, "read-only specialists cannot delegate to unchecked subagents" if Array(agent_options[:sub_agents]).any?
|
|
210
|
+
Agent.new(name: "oracle", description: "Read-only expert analysis and advice.", model: model,
|
|
211
|
+
tools: configured, client: client, instructions: combine(ORACLE_INSTRUCTIONS, instructions), **agent_options, inherit_globals: false)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def librarian(repository:, model:, client: nil, github_client: nil, token: ENV["GITHUB_TOKEN"], tools: [], instructions: nil, **agent_options)
|
|
215
|
+
require_model!(model)
|
|
216
|
+
repository_tool = GitHubRepository.new(repository: repository, client: github_client, token: token)
|
|
217
|
+
validate_read_only!(Array(tools) + skill_tools(agent_options))
|
|
218
|
+
raise ArgumentError, "read-only specialists cannot delegate to unchecked subagents" if Array(agent_options[:sub_agents]).any?
|
|
219
|
+
Agent.new(name: "librarian", description: "Source-backed research in an external GitHub repository.", model: model,
|
|
220
|
+
tools: [ repository_tool, *Array(tools) ], client: client,
|
|
221
|
+
instructions: combine(LIBRARIAN_INSTRUCTIONS, instructions), **agent_options, inherit_globals: false)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def painter(model:, image_model:, authorization:, client: nil, tools: [], instructions: nil, provider: nil, size: nil,
|
|
225
|
+
params: {}, max_reference_images: nil, **agent_options)
|
|
226
|
+
require_model!(model)
|
|
227
|
+
require_model!(image_model)
|
|
228
|
+
image_tool = PaintImage.new(model: image_model, authorization: authorization, provider: provider, size: size,
|
|
229
|
+
params: params, max_reference_images: max_reference_images)
|
|
230
|
+
Agent.new(name: "painter", description: "Authorized image generation and editing.", model: model,
|
|
231
|
+
tools: [ image_tool, *Array(tools) ], client: client,
|
|
232
|
+
instructions: combine(PAINTER_INSTRUCTIONS, instructions), **agent_options, inherit_globals: false)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def skill_tools(options)
|
|
236
|
+
(Array(options[:skills]) + Array(options[:available_skills])).flat_map(&:tools)
|
|
237
|
+
end
|
|
238
|
+
private_class_method :skill_tools
|
|
239
|
+
|
|
240
|
+
def validate_read_only!(tools)
|
|
241
|
+
invalid = tools.reject { |tool| tool.respond_to?(:read_only?) && tool.read_only? == true }
|
|
242
|
+
raise ArgumentError, "read-only specialist tools must explicitly report read_only? == true" if invalid.any?
|
|
243
|
+
end
|
|
244
|
+
private_class_method :validate_read_only!
|
|
245
|
+
|
|
246
|
+
def require_model!(model)
|
|
247
|
+
raise ArgumentError, "model is required" if model.nil? || model.to_s.empty?
|
|
248
|
+
end
|
|
249
|
+
private_class_method :require_model!
|
|
250
|
+
|
|
251
|
+
def combine(base, extra) = [ base, extra.to_s.strip ].reject(&:empty?).join("\n\n")
|
|
252
|
+
private_class_method :combine
|
|
253
|
+
end
|
|
254
|
+
end
|