conductor_ruby 0.1.0 → 0.1.1

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/examples/agents/01_basic_agent.rb +36 -0
  4. data/examples/agents/02a_simple_tools.rb +47 -0
  5. data/examples/agents/02c_tool_retry_config.rb +52 -0
  6. data/examples/agents/04_http_and_mcp_tools.rb +58 -0
  7. data/examples/agents/05_handoffs.rb +71 -0
  8. data/examples/agents/06_sequential_pipeline.rb +47 -0
  9. data/examples/agents/07_parallel_agents.rb +52 -0
  10. data/examples/agents/09_human_in_the_loop.rb +59 -0
  11. data/examples/agents/09c_hitl_streaming.rb +65 -0
  12. data/examples/agents/103_plan_and_compile.rb +85 -0
  13. data/examples/agents/10_guardrails.rb +61 -0
  14. data/examples/agents/13_hierarchical_agents.rb +79 -0
  15. data/examples/agents/16e_credentials_http_tool.rb +44 -0
  16. data/examples/agents/17_swarm_orchestration.rb +56 -0
  17. data/examples/agents/21_regex_guardrails.rb +69 -0
  18. data/examples/agents/22_llm_guardrails.rb +51 -0
  19. data/examples/agents/33_external_workers.rb +65 -0
  20. data/examples/agents/64_swarm_with_tools.rb +71 -0
  21. data/examples/agents/66_handoff_to_parallel.rb +62 -0
  22. data/examples/agents/bug_desk.rb +50 -0
  23. data/examples/agents/catalog.rb +32 -0
  24. data/examples/agents/dump_agent_configs.rb +30 -0
  25. data/examples/agents/external_workers.rb +39 -0
  26. data/examples/agents/golden_agents.rb +385 -0
  27. data/examples/agents/support_approval.rb +51 -0
  28. data/examples/agents/weather.rb +27 -0
  29. data/lib/conductor/agents/agent.rb +324 -0
  30. data/lib/conductor/agents/callback_handler.rb +64 -0
  31. data/lib/conductor/agents/config_serializer.rb +246 -0
  32. data/lib/conductor/agents/errors.rb +26 -0
  33. data/lib/conductor/agents/guardrail.rb +142 -0
  34. data/lib/conductor/agents/handoff.rb +94 -0
  35. data/lib/conductor/agents/memory.rb +75 -0
  36. data/lib/conductor/agents/plans.rb +22 -0
  37. data/lib/conductor/agents/prompt_template.rb +23 -0
  38. data/lib/conductor/agents/runtime/agent_config.rb +52 -0
  39. data/lib/conductor/agents/runtime/agent_runtime.rb +260 -0
  40. data/lib/conductor/agents/runtime/approval_request.rb +111 -0
  41. data/lib/conductor/agents/runtime/dispatch.rb +137 -0
  42. data/lib/conductor/agents/runtime/execution.rb +265 -0
  43. data/lib/conductor/agents/runtime/secrets.rb +54 -0
  44. data/lib/conductor/agents/runtime/sse_client.rb +175 -0
  45. data/lib/conductor/agents/runtime/status_poller.rb +49 -0
  46. data/lib/conductor/agents/runtime/system_workers.rb +115 -0
  47. data/lib/conductor/agents/runtime/tool_call.rb +13 -0
  48. data/lib/conductor/agents/runtime/tool_registry.rb +164 -0
  49. data/lib/conductor/agents/termination.rb +192 -0
  50. data/lib/conductor/agents/tool.rb +283 -0
  51. data/lib/conductor/agents/tools/schema_builder.rb +190 -0
  52. data/lib/conductor/agents/tools/secret_scanner.rb +45 -0
  53. data/lib/conductor/agents/tools.rb +173 -0
  54. data/lib/conductor/agents.rb +75 -0
  55. data/lib/conductor/client/agent_client.rb +117 -0
  56. data/lib/conductor/client/task_client.rb +7 -0
  57. data/lib/conductor/configuration.rb +43 -10
  58. data/lib/conductor/exceptions.rb +36 -0
  59. data/lib/conductor/http/api/agent_resource_api.rb +138 -0
  60. data/lib/conductor/http/api/scheduler_resource_api.rb +31 -12
  61. data/lib/conductor/http/api/task_resource_api.rb +15 -0
  62. data/lib/conductor/http/models/task.rb +10 -2
  63. data/lib/conductor/http/models/task_def.rb +14 -3
  64. data/lib/conductor/http/rest_client.rb +9 -0
  65. data/lib/conductor/orkes/orkes_clients.rb +5 -1
  66. data/lib/conductor/version.rb +1 -1
  67. data/lib/conductor/worker/lease_renewer.rb +55 -0
  68. data/lib/conductor/worker/ractor_task_runner.rb +1 -1
  69. data/lib/conductor/worker/task_definition_registrar.rb +2 -2
  70. data/lib/conductor/worker/task_runner.rb +43 -10
  71. data/lib/conductor/worker/worker.rb +3 -2
  72. data/lib/conductor/worker/worker_config.rb +2 -1
  73. data/lib/conductor.rb +3 -1
  74. metadata +71 -16
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+ require_relative 'tool_call'
5
+
6
+ module Conductor
7
+ module Agents
8
+ # A tool call (or batch of them) waiting for a human decision.
9
+ #
10
+ # agent.on_approval do |request|
11
+ # request.amount < 100 ? request.approve : request.reject('Needs a manager')
12
+ # end
13
+ #
14
+ # The server pauses on one HUMAN task per turn, so a request may carry several tool
15
+ # calls; +tool_name+ and the argument accessors (request.amount) read the first one.
16
+ class ApprovalRequest
17
+ attr_reader :execution_id, :task_ref_name, :tool_calls, :response_schema, :raw
18
+
19
+ # @param pending_tool [Hash] the SSE "waiting" event's pendingTool payload
20
+ def initialize(execution_id, pending_tool, client:, execution: nil)
21
+ @execution_id = execution_id
22
+ @client = client
23
+ @execution = execution
24
+ @raw = pending_tool || {}
25
+ @task_ref_name = @raw['taskRefName']
26
+ @response_schema = @raw['response_schema']
27
+ @tool_calls = extract_tool_calls(@raw)
28
+ @responded = false
29
+ end
30
+
31
+ # First tool call's name
32
+ def tool_name
33
+ @tool_calls.first&.name
34
+ end
35
+
36
+ # First tool call's arguments (String keys)
37
+ def arguments
38
+ @tool_calls.first&.arguments || {}
39
+ end
40
+
41
+ def responded?
42
+ @responded
43
+ end
44
+
45
+ # Let the tool run
46
+ def approve
47
+ complete_response { @client.approve(@execution_id) }
48
+ end
49
+
50
+ # Skip the tool; the run ends COMPLETED with finish_reason :rejected
51
+ def reject(reason = '')
52
+ complete_response { @client.reject(@execution_id, reason) }
53
+ end
54
+
55
+ # Free-text answer (human tools / feedback)
56
+ def send_message(message)
57
+ complete_response { @client.send_message(@execution_id, message) }
58
+ end
59
+
60
+ # Submit fields requested by response_schema (approval plus reviewer feedback,
61
+ # or structured input for a human tool).
62
+ def respond(body)
63
+ complete_response { @client.respond(@execution_id, body) }
64
+ end
65
+
66
+ # request.amount, request.order_id ... read the first tool call's arguments
67
+ def method_missing(name, *args, &block)
68
+ key = name.to_s
69
+ return arguments[key] if args.empty? && arguments.key?(key)
70
+
71
+ super
72
+ end
73
+
74
+ def respond_to_missing?(name, include_private = false)
75
+ arguments.key?(name.to_s) || super
76
+ end
77
+
78
+ def to_s
79
+ calls = @tool_calls.map(&:to_s).join(', ')
80
+ "#<Conductor::Agents::ApprovalRequest #{@execution_id} #{calls}>"
81
+ end
82
+ alias inspect to_s
83
+
84
+ private
85
+
86
+ def complete_response
87
+ raise Error, 'approval request already answered' if @responded
88
+
89
+ yield
90
+ @responded = true
91
+ @execution&.clear_waiting
92
+ self
93
+ end
94
+
95
+ def extract_tool_calls(raw)
96
+ calls = raw['toolCalls'] || raw['tool_calls']
97
+ if calls.is_a?(Array) && !calls.empty?
98
+ return calls.map do |c|
99
+ c = c.transform_keys(&:to_s)
100
+ ToolCall.new(name: c['name'], arguments: (c['args'] || c['arguments'] || c['parameters'] || {}).transform_keys(&:to_s))
101
+ end
102
+ end
103
+
104
+ name = raw['tool_name'] || raw['toolName']
105
+ return [] if name.nil?
106
+
107
+ [ToolCall.new(name: name, arguments: (raw['parameters'] || raw['args'] || {}).transform_keys(&:to_s))]
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../errors'
5
+ require_relative 'secrets'
6
+ require_relative '../../http/models/task_result'
7
+
8
+ module Conductor
9
+ module Agents
10
+ # Executes one tool task: maps the task's inputData onto the tool method's keyword
11
+ # arguments, runs it, and shapes the result for the server.
12
+ #
13
+ # The server sends the LLM's arguments as top-level keys plus a few injected keys
14
+ # (method, _agent_state, _agent_tool_name, _allowed_commands) that are stripped here.
15
+ # Missing required arguments, missing credentials and unserializable results are
16
+ # terminal failures; anything raised by the tool itself is a retryable failure.
17
+ module Dispatch
18
+ INJECTED_KEYS = %w[method _agent_state _agent_tool_name _allowed_commands __conductor_agent_ctx__].freeze
19
+ WORKER_ID = 'agent-sdk'
20
+ TRUE_STRINGS = %w[true 1 yes].freeze
21
+ FALSE_STRINGS = %w[false 0 no].freeze
22
+
23
+ # Raised when the LLM omitted a required argument
24
+ class MissingArgumentError < Error; end
25
+
26
+ module_function
27
+
28
+ # @param task [Http::Models::Task]
29
+ # @param tool_def [Tool]
30
+ # @return [Http::Models::TaskResult]
31
+ def run_tool_task(task, tool_def, logger: nil)
32
+ result = base_result(task)
33
+ input = (task.input_data || {}).transform_keys(&:to_s)
34
+ args = input.except(*INJECTED_KEYS)
35
+
36
+ check_credentials!(tool_def)
37
+ kwargs = coerce_args(args, tool_def)
38
+ output = tool_def.func.call(**kwargs)
39
+ return output if output.is_a?(Http::Models::TaskResult)
40
+
41
+ result.status = Http::Models::TaskResultStatus::COMPLETED
42
+ result.output_data = normalize_output(tool_def, output)
43
+ result
44
+ rescue MissingArgumentError, CredentialNotFoundError, ToolSerializationError => e
45
+ logger&.error("tool #{tool_def.name}: #{e.message}")
46
+ terminal_failure(result, e)
47
+ rescue StandardError => e
48
+ logger&.error("tool #{tool_def.name} raised #{e.class}: #{e.message}")
49
+ result.status = Http::Models::TaskResultStatus::FAILED
50
+ result.reason_for_incompletion = "#{e.class}: #{e.message}"
51
+ result
52
+ end
53
+
54
+ # Map input keys to the tool's keyword arguments, coercing by the JSON schema
55
+ # @return [Hash<Symbol, Object>]
56
+ def coerce_args(args, tool_def)
57
+ schema = tool_def.input_schema || {}
58
+ properties = schema['properties'] || {}
59
+ required = Array(schema['required'])
60
+ accepts_rest = tool_def.func.respond_to?(:parameters) && tool_def.func.parameters.any? { |kind, _| kind == :keyrest }
61
+
62
+ missing = required.reject { |name| args.key?(name) }
63
+ raise MissingArgumentError, "tool #{tool_def.name}: missing required argument(s) #{missing.join(', ')}" unless missing.empty?
64
+
65
+ args.each_with_object({}) do |(name, value), kwargs|
66
+ if properties.key?(name)
67
+ kwargs[name.to_sym] = coerce_value(value, properties[name])
68
+ elsif accepts_rest || properties.empty?
69
+ kwargs[name.to_sym] = value
70
+ end
71
+ end
72
+ end
73
+
74
+ # Coerce one value to its JSON schema type (LLMs often send numbers and JSON as strings)
75
+ def coerce_value(value, property)
76
+ return value unless property.is_a?(Hash)
77
+
78
+ type = property['type']
79
+ case type
80
+ when 'integer'
81
+ value.is_a?(String) ? (Integer(value, 10) rescue value) : value # rubocop:disable Style/RescueModifier
82
+ when 'number'
83
+ value.is_a?(String) ? (Float(value) rescue value) : value # rubocop:disable Style/RescueModifier
84
+ when 'boolean'
85
+ return value unless value.is_a?(String)
86
+
87
+ lower = value.strip.downcase
88
+ return true if TRUE_STRINGS.include?(lower)
89
+ return false if FALSE_STRINGS.include?(lower)
90
+
91
+ value
92
+ when 'array', 'object'
93
+ return value unless value.is_a?(String)
94
+
95
+ parsed = JSON.parse(value)
96
+ expected = type == 'array' ? Array : Hash
97
+ parsed.is_a?(expected) ? parsed : value
98
+ when 'string'
99
+ value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value
100
+ else
101
+ value
102
+ end
103
+ rescue JSON::ParserError
104
+ value
105
+ end
106
+
107
+ # Hash results go out as-is; anything else is wrapped as { "result" => value }
108
+ def normalize_output(tool_def, output)
109
+ data = output.is_a?(Hash) ? output.transform_keys(&:to_s) : { 'result' => output }
110
+ begin
111
+ JSON.generate(data)
112
+ rescue StandardError => e
113
+ raise ToolSerializationError, "tool #{tool_def.name} returned a non-JSON-serializable result: #{e.message}"
114
+ end
115
+ data
116
+ end
117
+
118
+ def check_credentials!(tool_def)
119
+ tool_def.credentials.each { |name| Secrets.secret(name) }
120
+ end
121
+
122
+ def base_result(task)
123
+ result = Http::Models::TaskResult.new
124
+ result.task_id = task.task_id
125
+ result.workflow_instance_id = task.workflow_instance_id
126
+ result.worker_id = WORKER_ID
127
+ result
128
+ end
129
+
130
+ def terminal_failure(result, error)
131
+ result.status = Http::Models::TaskResultStatus::FAILED_WITH_TERMINAL_ERROR
132
+ result.reason_for_incompletion = error.message
133
+ result
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+ require_relative '../errors'
5
+ require_relative 'approval_request'
6
+
7
+ module Conductor
8
+ module Agents
9
+ # Token usage for an execution (summed over sub-agent executions)
10
+ TokenUsage = Struct.new(:prompt_tokens, :completion_tokens, :total_tokens, keyword_init: true) do
11
+ def initialize(prompt_tokens: 0, completion_tokens: 0, total_tokens: 0)
12
+ super
13
+ end
14
+
15
+ def +(other)
16
+ TokenUsage.new(prompt_tokens: prompt_tokens + other.prompt_tokens,
17
+ completion_tokens: completion_tokens + other.completion_tokens,
18
+ total_tokens: total_tokens + other.total_tokens)
19
+ end
20
+ end
21
+
22
+ # Maps the server's status + output.finishReason to a Symbol
23
+ module FinishReason
24
+ def self.derive(status, output)
25
+ case status.to_s
26
+ when 'COMPLETED'
27
+ fr = output.is_a?(Hash) ? output['finishReason'].to_s : ''
28
+ case fr
29
+ when 'rejected' then :rejected
30
+ when 'LENGTH', 'MAX_TOKENS' then :length
31
+ when 'tool_calls', 'TOOL_CALLS' then :tool_calls
32
+ when 'CONTENT_FILTER' then :content_filter
33
+ else :stop
34
+ end
35
+ when 'FAILED' then :error
36
+ when 'TERMINATED' then :cancelled
37
+ when 'TIMED_OUT' then :timeout
38
+ else :stop
39
+ end
40
+ end
41
+ end
42
+
43
+ # Handle on a running (or finished) agent execution.
44
+ #
45
+ # execution = agent.call_async('Refund order A-1029')
46
+ # execution.done? # false until finished
47
+ # execution.partial_text # streamed text so far
48
+ # execution.result # blocks for the answer
49
+ # execution.finish_reason # :stop | :rejected | ...
50
+ class Execution
51
+ TERMINAL_STATUSES = %w[COMPLETED FAILED TERMINATED TIMED_OUT].freeze
52
+
53
+ attr_reader :execution_id, :agent_name, :tool_calls, :token_usage, :pending, :error, :status, :output, :events
54
+
55
+ # @param execution_id [String] also the Conductor workflow id
56
+ # @param client [Client::AgentClient]
57
+ def initialize(execution_id, client:, agent_name: nil, runtime: nil)
58
+ @execution_id = execution_id
59
+ @client = client
60
+ @agent_name = agent_name
61
+ @runtime = runtime
62
+ @mutex = Mutex.new
63
+ @done_cv = ConditionVariable.new
64
+ @partial_text = +''
65
+ @tool_calls = []
66
+ @events = []
67
+ @token_usage = TokenUsage.new
68
+ @pending = nil
69
+ @status = 'RUNNING'
70
+ @output = nil
71
+ @result = nil
72
+ @error = nil
73
+ @waiting = false
74
+ @finished = false
75
+ end
76
+
77
+ # Look up an execution by id (a snapshot; +result+ attaches a stream if still running)
78
+ # @return [Execution]
79
+ def self.find(execution_id, runtime: Conductor::Agents.runtime)
80
+ execution = new(execution_id, client: runtime.client, runtime: runtime)
81
+ execution.refresh!
82
+ execution
83
+ end
84
+
85
+ # Re-read status from the server
86
+ def refresh!
87
+ status = @client.get_status(@execution_id)
88
+ @agent_name ||= status['agentName']
89
+ if status['isComplete']
90
+ finish(status: status['status'], output: status['output'], reason: status['reasonForIncompletion'])
91
+ elsif status['isWaiting']
92
+ mark_waiting(ApprovalRequest.new(@execution_id, status['pendingTool'], client: @client, execution: self))
93
+ else
94
+ clear_waiting
95
+ end
96
+ self
97
+ end
98
+
99
+ def done?
100
+ @mutex.synchronize { @finished }
101
+ end
102
+
103
+ def waiting?
104
+ @mutex.synchronize { @waiting && !@finished }
105
+ end
106
+
107
+ # Text streamed so far (never behind +result+ once done)
108
+ def partial_text
109
+ @mutex.synchronize { @partial_text.dup }
110
+ end
111
+
112
+ # Block until finished and return the answer
113
+ # @param timeout [Numeric, nil] seconds; nil waits forever
114
+ # @raise [Error] when the execution failed, was cancelled or timed out on the server
115
+ # @raise [Timeout::Error] when +timeout+ elapses first
116
+ def result(timeout: nil)
117
+ @runtime&.attach(self) unless done? || @attached
118
+ @mutex.synchronize do
119
+ deadline = timeout && (Time.now + timeout)
120
+ until @finished
121
+ remaining = deadline && (deadline - Time.now)
122
+ raise Timeout::Error, "execution #{@execution_id} still running after #{timeout}s" if remaining && remaining <= 0
123
+
124
+ @done_cv.wait(@mutex, remaining)
125
+ end
126
+ raise Error, "execution #{@execution_id} #{@status.downcase}: #{@error}" if @error && @status != 'COMPLETED'
127
+
128
+ @result
129
+ end
130
+ end
131
+
132
+ # The answer without blocking or raising (nil while running or when failed)
133
+ def answer
134
+ @mutex.synchronize { @finished ? @result : nil }
135
+ end
136
+
137
+ # @return [Symbol] :stop | :tool_calls | :length | :content_filter | :rejected | :error | :cancelled | :timeout | nil
138
+ def finish_reason
139
+ @mutex.synchronize { @finished ? FinishReason.derive(@status, @output) : nil }
140
+ end
141
+
142
+ def rejected?
143
+ finish_reason == :rejected
144
+ end
145
+
146
+ # ── control ─────────────────────────────────────────────────────
147
+
148
+ def pause
149
+ @client.pause(@execution_id)
150
+ self
151
+ end
152
+
153
+ def resume
154
+ @client.resume(@execution_id)
155
+ self
156
+ end
157
+
158
+ def cancel(reason: 'cancelled by client')
159
+ @client.cancel(@execution_id, reason: reason)
160
+ self
161
+ end
162
+
163
+ def stop
164
+ @client.stop(@execution_id)
165
+ self
166
+ end
167
+
168
+ def signal(message)
169
+ @client.signal(@execution_id, message)
170
+ self
171
+ end
172
+
173
+ # Approve / reject the pending tool call, if any
174
+ def approve
175
+ (pending || raise(Error, 'nothing is waiting for approval')).approve
176
+ end
177
+
178
+ def reject(reason = '')
179
+ (pending || raise(Error, 'nothing is waiting for approval')).reject(reason)
180
+ end
181
+
182
+ # ── mutators used by the runtime's stream thread ────────────────
183
+
184
+ def attached!
185
+ @attached = true
186
+ end
187
+
188
+ def record_event(event)
189
+ @mutex.synchronize { @events << event }
190
+ end
191
+
192
+ def append_text(text)
193
+ return if text.nil? || text.to_s.empty?
194
+
195
+ @mutex.synchronize { @partial_text << text.to_s }
196
+ end
197
+
198
+ def add_tool_call(name, arguments)
199
+ @mutex.synchronize { @tool_calls << ToolCall.new(name: name, arguments: arguments || {}) }
200
+ end
201
+
202
+ def add_tool_result(name, result)
203
+ @mutex.synchronize do
204
+ call = @tool_calls.reverse.find { |c| c.name == name && c.result.nil? }
205
+ call ? call.result = result : @tool_calls << ToolCall.new(name: name, arguments: {}, result: result)
206
+ end
207
+ end
208
+
209
+ def mark_waiting(pending)
210
+ @mutex.synchronize do
211
+ @waiting = true
212
+ @pending = pending
213
+ end
214
+ end
215
+
216
+ def clear_waiting
217
+ @mutex.synchronize do
218
+ @waiting = false
219
+ @pending = nil
220
+ end
221
+ end
222
+
223
+ def token_usage=(usage)
224
+ @mutex.synchronize { @token_usage = usage }
225
+ end
226
+
227
+ # Mark the execution finished. +output+ is the workflow output ({result, finishReason, ...}).
228
+ def finish(status:, output:, reason: nil)
229
+ @mutex.synchronize do
230
+ return if @finished
231
+
232
+ @status = status.to_s
233
+ @output = output.is_a?(Hash) ? output : {}
234
+ @result = extract_result(output)
235
+ @error = reason if reason && !reason.to_s.empty?
236
+ @error ||= (@output['error'] || @output['reason']) unless @status == 'COMPLETED'
237
+ @error ||= "execution #{@status.downcase}" unless @status == 'COMPLETED'
238
+ @partial_text = @result.to_s.dup if @result.is_a?(String) && @partial_text.empty?
239
+ @waiting = false
240
+ @pending = nil
241
+ @finished = true
242
+ @done_cv.broadcast
243
+ end
244
+ end
245
+
246
+ def fail(reason)
247
+ finish(status: 'FAILED', output: { 'error' => reason }, reason: reason)
248
+ end
249
+
250
+ def to_s
251
+ "#<Conductor::Agents::Execution #{@execution_id} #{@status}#{@finished ? '' : ' (running)'}>"
252
+ end
253
+ alias inspect to_s
254
+
255
+ private
256
+
257
+ def extract_result(output)
258
+ return output unless output.is_a?(Hash)
259
+ return output['result'] if output.key?('result')
260
+
261
+ output
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+ require_relative '../../worker/task_context'
5
+
6
+ module Conductor
7
+ module Agents
8
+ # Read secrets inside a tool body.
9
+ #
10
+ # tool def create_issue(title: String)
11
+ # Github.create_issue(title, token: secret('GH_TOKEN'))
12
+ # end
13
+ #
14
+ # The literal name is also the declaration: the Tools DSL scans the body and puts
15
+ # GH_TOKEN on the tool's TaskDef#runtime_metadata. The server resolves it from its
16
+ # secret store at poll time and delivers the value on Task#runtime_metadata, which
17
+ # TaskContext (thread/fiber-local) exposes to the running tool. Nothing is ever written
18
+ # to ENV; for subprocesses use secrets_env.
19
+ module Secrets
20
+ module_function
21
+
22
+ # @param name [String, Symbol]
23
+ # @return [String]
24
+ # @raise [CredentialNotFoundError] when neither the task nor ENV has it
25
+ def secret(name)
26
+ key = name.to_s
27
+ value = task_secrets[key]
28
+ value = ENV.fetch(key, nil) if value.nil?
29
+ return value unless value.nil?
30
+
31
+ raise CredentialNotFoundError,
32
+ "secret #{key.inspect} not found: it was not delivered on the task's runtimeMetadata " \
33
+ "and ENV[#{key.inspect}] is unset. Store it on the server (conductor secrets put #{key} ...) " \
34
+ 'or declare it with add_tool ..., credentials: [...]'
35
+ end
36
+
37
+ # Environment hash for system / spawn / Open3: { 'GH_TOKEN' => '...' }
38
+ # @return [Hash<String, String>]
39
+ def secrets_env(*names)
40
+ names.flatten.each_with_object({}) { |n, env| env[n.to_s] = secret(n) }
41
+ end
42
+
43
+ # Secrets bound for the current task (wire-only Task#runtime_metadata)
44
+ # @return [Hash<String, String>]
45
+ def task_secrets
46
+ ctx = Conductor::Worker::TaskContext.current
47
+ task = ctx&.task
48
+ return {} unless task.respond_to?(:runtime_metadata)
49
+
50
+ task.runtime_metadata || {}
51
+ end
52
+ end
53
+ end
54
+ end