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
@@ -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 = Mutex.new
8
+ @mutex = Monitor.new
7
9
  @conversations = {}
8
10
  @turns = {}
9
11
  @messages = {}
10
12
  @tool_executions = {}
13
+ @deliveries = {}
14
+ @delivery_keys = {}
15
+ @waits = {}
11
16
  @message_sequences = Hash.new(0)
17
+ @transaction_depth = 0
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
- attrs = stringify(attributes)
37
- attrs["sequence"] ||= next_message_sequence(attrs.fetch("conversation_id"))
38
- message = Record.message(attrs)
39
- @mutex.synchronize { @messages[message.fetch("id")] = message }
40
- duplicate(message)
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
- [ to_h ]
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. TurnKit never reruns an interrupted tool; the synthetic
8
- # result tells the continued model the outcome is unknown.
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).each do |turn|
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" => { "error" => true, "message" => INTERRUPTED_MESSAGE }.to_json, "error" => true } ],
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" => true }
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
@@ -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