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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'logger'
7
+ require_relative '../errors'
8
+
9
+ module Conductor
10
+ module Agents
11
+ # Server-sent events from GET /api/agent/stream/{executionId}.
12
+ #
13
+ # Uses a plain Net::HTTP streaming request (the shared Faraday RestClient buffers
14
+ # bodies, retries, and has a 120 s total timeout, none of which suit a long-lived
15
+ # stream). Auth headers come from the ApiClient so token refresh stays in one place.
16
+ #
17
+ # Wire format (see AgentStreamRegistry on the server): ":connected" first, then
18
+ # "id:<n>\nevent:<type>\ndata:<json>\n\n" frames, a ":heartbeat" comment every 15 s, the
19
+ # stream closes after "done" or "error". Last-Event-ID (a bare integer) resumes; without
20
+ # it the server replays from the start, so connecting after start loses nothing.
21
+ class SseClient
22
+ HEARTBEAT_ONLY_TIMEOUT = 15
23
+ RECONNECT_DELAY = 1
24
+ TERMINAL_EVENTS = %w[done error].freeze
25
+ READ_TIMEOUT = 60
26
+ OPEN_TIMEOUT = 5
27
+
28
+ # Incremental parser for the SSE wire format
29
+ class Parser
30
+ def initialize
31
+ @buffer = +''
32
+ @event = nil
33
+ @id = nil
34
+ @data = []
35
+ end
36
+
37
+ # Feed a chunk; yields each complete event as { 'event', 'id', 'data' } or { 'heartbeat' => true }
38
+ def feed(chunk, &block)
39
+ @buffer << chunk
40
+ while (idx = @buffer.index("\n"))
41
+ line = @buffer.slice!(0..idx).chomp
42
+ process_line(line, &block)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def process_line(line, &block)
49
+ if line.start_with?(':')
50
+ yield({ 'heartbeat' => true })
51
+ elsif line.empty?
52
+ flush(&block)
53
+ elsif (m = line.match(/\A(\w+):\s?(.*)\z/m))
54
+ field = m[1]
55
+ value = m[2]
56
+ case field
57
+ when 'event' then @event = value
58
+ when 'id' then @id = value
59
+ when 'data' then @data << value
60
+ end
61
+ end
62
+ end
63
+
64
+ def flush
65
+ return if @data.empty? && @event.nil?
66
+
67
+ raw = @data.join("\n")
68
+ data = begin
69
+ raw.empty? ? {} : JSON.parse(raw)
70
+ rescue JSON::ParserError
71
+ { 'content' => raw }
72
+ end
73
+ data = { 'content' => data } unless data.is_a?(Hash)
74
+ id = @id.to_s =~ /\A\d+\z/ ? @id.to_i : @id
75
+ yield({ 'event' => @event || data['type'], 'id' => id, 'data' => data })
76
+ ensure
77
+ @event = nil
78
+ @id = nil
79
+ @data = []
80
+ end
81
+ end
82
+
83
+ # @param api_client [Http::ApiClient] supplies base URL, TLS settings and auth headers
84
+ def initialize(api_client, logger: nil)
85
+ @api_client = api_client
86
+ @configuration = api_client.configuration
87
+ @logger = logger || Logger.new($stdout, level: Logger::INFO)
88
+ end
89
+
90
+ # Yield every real event for +execution_id+ until done/error, reconnecting on drops.
91
+ # @param last_event_id [Integer, nil] resume point
92
+ # @raise [SseUnavailableError] when the first connection fails or only heartbeats arrive
93
+ def each_event(execution_id, last_event_id: nil)
94
+ return enum_for(:each_event, execution_id, last_event_id: last_event_id) unless block_given?
95
+
96
+ first_connect = true
97
+ got_real_event = false
98
+
99
+ loop do
100
+ begin
101
+ finished = connect(execution_id, last_event_id) do |event|
102
+ if event['heartbeat']
103
+ next unless !got_real_event && Time.now - @connected_at > HEARTBEAT_ONLY_TIMEOUT
104
+
105
+ raise SseUnavailableError, "SSE connected but only heartbeats arrived for #{HEARTBEAT_ONLY_TIMEOUT}s"
106
+ end
107
+ first_connect = false
108
+ got_real_event = true
109
+ last_event_id = event['id'] if event['id'].is_a?(Integer)
110
+ yield event
111
+ return if TERMINAL_EVENTS.include?(event['event'].to_s)
112
+ end
113
+ first_connect = false
114
+ return if finished
115
+ rescue SseUnavailableError
116
+ raise
117
+ rescue StandardError => e
118
+ raise SseUnavailableError, "SSE unavailable: #{e.class}: #{e.message}" if first_connect
119
+
120
+ @logger.warn("SSE connection lost (#{e.class}: #{e.message}), reconnecting in #{RECONNECT_DELAY}s")
121
+ end
122
+ sleep RECONNECT_DELAY
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ # Open one streaming request. Returns true when a terminal event was seen, false on EOF.
129
+ def connect(execution_id, last_event_id)
130
+ uri = URI.parse("#{@configuration.server_url}/agent/stream/#{execution_id}")
131
+ request = Net::HTTP::Get.new(uri)
132
+ request['Accept'] = 'text/event-stream'
133
+ request['Cache-Control'] = 'no-cache'
134
+ request['Last-Event-ID'] = last_event_id.to_s if last_event_id
135
+ auth_headers.each { |k, v| request[k] = v }
136
+
137
+ parser = Parser.new
138
+ terminal = false
139
+ Net::HTTP.start(uri.host, uri.port, **http_options(uri)) do |http|
140
+ http.request(request) do |response|
141
+ raise SseUnavailableError, "SSE endpoint returned HTTP #{response.code}" unless response.code.to_i == 200
142
+
143
+ @connected_at = Time.now
144
+ response.read_body do |chunk|
145
+ parser.feed(chunk) do |event|
146
+ yield event
147
+ terminal = true if TERMINAL_EVENTS.include?(event['event'].to_s)
148
+ end
149
+ break if terminal
150
+ end
151
+ end
152
+ end
153
+ terminal
154
+ end
155
+
156
+ def auth_headers
157
+ return {} unless @configuration.auth_configured?
158
+
159
+ @api_client.get_authentication_headers || {}
160
+ rescue StandardError => e
161
+ @logger.warn("Could not attach auth headers to SSE request: #{e.message}")
162
+ {}
163
+ end
164
+
165
+ def http_options(uri)
166
+ opts = { use_ssl: uri.scheme == 'https', read_timeout: READ_TIMEOUT, open_timeout: OPEN_TIMEOUT }
167
+ if opts[:use_ssl]
168
+ opts[:verify_mode] = @configuration.verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
169
+ opts[:ca_file] = @configuration.ssl_ca_cert if @configuration.ssl_ca_cert
170
+ end
171
+ opts
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Conductor
4
+ module Agents
5
+ # Polling fallback for servers without SSE: turns GET /agent/{id}/status into the
6
+ # same event hashes the SseClient yields (waiting, done, error). No partial text.
7
+ class StatusPoller
8
+ def initialize(client, interval: 0.5, logger: nil)
9
+ @client = client
10
+ @interval = interval
11
+ @logger = logger
12
+ end
13
+
14
+ def each_event(execution_id)
15
+ return enum_for(:each_event, execution_id) unless block_given?
16
+
17
+ was_waiting = false
18
+ loop do
19
+ status = @client.get_status(execution_id)
20
+ if status['isComplete']
21
+ yield terminal_event(status)
22
+ return
23
+ end
24
+
25
+ if status['isWaiting'] && !was_waiting
26
+ yield({ 'event' => 'waiting', 'id' => nil, 'data' => { 'type' => 'waiting', 'executionId' => execution_id,
27
+ 'pendingTool' => status['pendingTool'] || {} } })
28
+ end
29
+ was_waiting = status['isWaiting'] ? true : false
30
+ sleep(was_waiting ? [@interval * 4, 2.0].min : @interval)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def terminal_event(status)
37
+ if status['status'].to_s == 'COMPLETED'
38
+ { 'event' => 'done', 'id' => nil,
39
+ 'data' => { 'type' => 'done', 'executionId' => status['executionId'], 'output' => status['output'] || {} } }
40
+ else
41
+ { 'event' => 'error', 'id' => nil,
42
+ 'data' => { 'type' => 'error', 'executionId' => status['executionId'], 'status' => status['status'],
43
+ 'content' => status['reasonForIncompletion'] || "execution #{status['status']}",
44
+ 'output' => status['output'] } }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../errors'
5
+
6
+ module Conductor
7
+ module Agents
8
+ # Bodies for the compiler-generated SIMPLE tasks the server asks the SDK to serve
9
+ # (they appear in requiredWorkers next to the user's tools). Ports of the Python
10
+ # SDK's TerminationEntry, GuardrailEntry, CallbackEntry and OnCondition handoff workers.
11
+ module SystemWorkers
12
+ module_function
13
+
14
+ # <agent>_termination: { should_continue, reason }
15
+ def termination(condition, logger: nil)
16
+ lambda do |task|
17
+ input = stringify(task.input_data)
18
+ context = { 'result' => input['result'].to_s, 'messages' => input['messages'] || [],
19
+ 'iteration' => input['iteration'].to_i, 'token_usage' => input['token_usage'] }
20
+ begin
21
+ outcome = condition.should_terminate(context)
22
+ { 'should_continue' => !outcome.should_terminate, 'reason' => outcome.reason.to_s }
23
+ rescue StandardError => e
24
+ logger&.error("termination condition failed: #{e.class}: #{e.message}")
25
+ { 'should_continue' => true, 'reason' => '' }
26
+ end
27
+ end
28
+ end
29
+
30
+ # <guardrail name>: { passed, message, on_fail, fixed_output, guardrail_name, should_continue }
31
+ def guardrail(guardrail, logger: nil)
32
+ lambda do |task|
33
+ input = stringify(task.input_data)
34
+ content = stringify_content(input['content'])
35
+ iteration = input['iteration'].to_i
36
+ begin
37
+ result = guardrail.check(content)
38
+ return pass_result if result.passed?
39
+
40
+ on_fail = guardrail.on_fail
41
+ fixed = result.fixed_output
42
+ on_fail = 'raise' if on_fail == 'retry' && iteration >= guardrail.max_retries
43
+ on_fail = 'raise' if on_fail == 'fix' && fixed.nil?
44
+ { 'passed' => false, 'message' => result.message.to_s, 'on_fail' => on_fail, 'fixed_output' => fixed,
45
+ 'guardrail_name' => guardrail.name, 'should_continue' => on_fail == 'retry' }
46
+ rescue StandardError => e
47
+ logger&.error("guardrail #{guardrail.name} raised: #{e.class}: #{e.message}")
48
+ on_fail = guardrail.on_fail
49
+ on_fail = 'raise' if on_fail == 'retry' && iteration >= guardrail.max_retries
50
+ { 'passed' => false, 'message' => "Guardrail error: #{e.message}", 'on_fail' => on_fail, 'fixed_output' => nil,
51
+ 'guardrail_name' => guardrail.name, 'should_continue' => on_fail == 'retry' }
52
+ end
53
+ end
54
+ end
55
+
56
+ # <agent>_<position>: the callback chain's Hash (or {})
57
+ def callback(chain, logger: nil)
58
+ lambda do |task|
59
+ input = stringify(task.input_data)
60
+ kwargs = {}
61
+ kwargs[:messages] = input['messages'] if input.key?('messages')
62
+ kwargs[:llm_result] = input['llm_result'] if input.key?('llm_result')
63
+ begin
64
+ result = chain.call(**kwargs)
65
+ result.is_a?(Hash) ? result : {}
66
+ rescue StandardError => e
67
+ logger&.error("callback failed: #{e.class}: #{e.message}")
68
+ {}
69
+ end
70
+ end
71
+ end
72
+
73
+ # <agent>_handoff_<target>: { handoff, target }
74
+ def handoff(condition, logger: nil)
75
+ lambda do |task|
76
+ input = stringify(task.input_data)
77
+ begin
78
+ { 'handoff' => condition.should_handoff(input) ? true : false, 'target' => condition.target }
79
+ rescue StandardError => e
80
+ logger&.error("handoff condition failed: #{e.class}: #{e.message}")
81
+ { 'handoff' => false, 'target' => condition.target }
82
+ end
83
+ end
84
+ end
85
+
86
+ def pass_result
87
+ { 'passed' => true, 'message' => '', 'on_fail' => 'pass', 'fixed_output' => nil, 'guardrail_name' => '',
88
+ 'should_continue' => false }
89
+ end
90
+
91
+ # Function-based routers return the selected sub-agent's name.
92
+ def router(callable, agent_names, logger: nil)
93
+ lambda do |task|
94
+ { 'selected_agent' => callable.call(stringify(task.input_data).fetch('prompt', '')).to_s }
95
+ rescue StandardError => e
96
+ logger&.error("router failed: #{e.class}: #{e.message}")
97
+ { 'selected_agent' => agent_names.first || '' }
98
+ end
99
+ end
100
+
101
+ def stringify(input)
102
+ (input || {}).transform_keys(&:to_s)
103
+ end
104
+
105
+ def stringify_content(content)
106
+ return '' if content.nil?
107
+ return content if content.is_a?(String)
108
+
109
+ JSON.generate(content)
110
+ rescue StandardError
111
+ content.to_s
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Conductor
4
+ module Agents
5
+ # A tool call observed on the stream or awaiting approval
6
+ ToolCall = Struct.new(:name, :arguments, :result, keyword_init: true) do
7
+ def to_s
8
+ "#<ToolCall #{name} #{(arguments || {}).map { |k, v| "#{k}: #{v.inspect}" }.join(' ')}>"
9
+ end
10
+ alias_method :inspect, :to_s
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative '../errors'
5
+ require_relative 'dispatch'
6
+ require_relative 'system_workers'
7
+ require_relative '../../worker/worker'
8
+ require_relative '../../http/models/task_def'
9
+
10
+ module Conductor
11
+ module Agents
12
+ # Turns an agent tree into the Conductor workers this process must run: one per
13
+ # local tool (worker/cli tools with a func) and one per compiler-generated system task
14
+ # the server listed in requiredWorkers.
15
+ class ToolRegistry
16
+ SYSTEM_SUFFIX_TERMINATION = '_termination'
17
+
18
+ attr_reader :logger
19
+
20
+ def initialize(agent_config, logger: nil)
21
+ @agent_config = agent_config
22
+ @logger = logger
23
+ end
24
+
25
+ # @param agent [Agent] root agent
26
+ # @param required_workers [Array<String>, nil] from the start/deploy response (nil = register everything)
27
+ # @param domain [String, nil] task domain for stateful runs (the runId)
28
+ # @return [Array<Worker::Worker>]
29
+ def workers_for(agent, required_workers: nil, domain: nil)
30
+ required = required_workers.nil? ? nil : Set.new(required_workers.map(&:to_s))
31
+ workers = tool_workers(agent, domain: domain)
32
+ workers += system_workers(agent, required, domain: domain)
33
+ warn_unhandled(required, workers)
34
+ workers
35
+ end
36
+
37
+ # Workers for every local tool in the tree (deduplicated by name)
38
+ def tool_workers(agent, domain: nil)
39
+ seen = {}
40
+ each_agent_with_credentials(agent) do |a, credentials|
41
+ a.tools.each do |tool_def|
42
+ next unless tool_def.local?
43
+
44
+ if seen.key?(tool_def.name)
45
+ template = seen[tool_def.name].task_def_template
46
+ template.runtime_metadata = (template.runtime_metadata + credentials + tool_def.credentials).uniq
47
+ next
48
+ end
49
+
50
+ seen[tool_def.name] = build_tool_worker(tool_def, credentials: credentials, domain: domain)
51
+ end
52
+ end
53
+ seen.values
54
+ end
55
+
56
+ # Workers for compiler-generated tasks: termination, custom guardrails, callbacks, on_condition handoffs
57
+ def system_workers(agent, required, domain: nil)
58
+ workers = []
59
+ agent.all_agents.each do |a|
60
+ if a.termination
61
+ name = "#{a.name}#{SYSTEM_SUFFIX_TERMINATION}"
62
+ workers << build_system_worker(name, SystemWorkers.termination(a.termination, logger: @logger), domain, a) if wanted?(required, name)
63
+ end
64
+ (a.guardrails + a.tools.flat_map(&:guardrails)).each do |g|
65
+ next if g.external? || g.is_a?(RegexGuardrail) || g.is_a?(LlmGuardrail)
66
+
67
+ workers << build_system_worker(g.name, SystemWorkers.guardrail(g, logger: @logger), domain, a) if wanted?(required, g.name)
68
+ end
69
+ if a.router.respond_to?(:call)
70
+ name = "#{a.name}_router_fn"
71
+ workers << build_system_worker(name, SystemWorkers.router(a.router, a.agents.map(&:name), logger: @logger), domain, a) if wanted?(required, name)
72
+ end
73
+ a.callback_positions.each do |position|
74
+ name = "#{a.name}_#{position}"
75
+ next unless wanted?(required, name)
76
+
77
+ workers << build_system_worker(name, SystemWorkers.callback(a.callback_chain(position, logger: @logger), logger: @logger), domain, a)
78
+ end
79
+ handoffs = a.handoffs
80
+ handoffs += a.agents.flat_map(&:handoffs) if !a.strategy_set? || a.strategy == Strategy::SWARM
81
+ handoffs.each do |h|
82
+ next unless h.is_a?(Handoff::OnCondition)
83
+
84
+ name = "#{a.name}_handoff_#{h.target}"
85
+ workers << build_system_worker(name, SystemWorkers.handoff(h, logger: @logger), domain, a) if wanted?(required, name)
86
+ end
87
+ end
88
+ workers.uniq(&:task_definition_name)
89
+ end
90
+
91
+ # Task definition for a tool worker (same defaults as the Python SDK)
92
+ def task_def_for(name, retry_count: 2, retry_delay_seconds: 2, retry_logic: 'LINEAR_BACKOFF', credentials: [])
93
+ Http::Models::TaskDef.new(
94
+ name: name,
95
+ retry_count: retry_count,
96
+ retry_delay_seconds: retry_delay_seconds,
97
+ retry_logic: retry_logic,
98
+ timeout_seconds: 0,
99
+ response_timeout_seconds: 10,
100
+ timeout_policy: 'RETRY',
101
+ runtime_metadata: credentials.uniq
102
+ )
103
+ end
104
+
105
+ private
106
+
107
+ def wanted?(required, name)
108
+ required.nil? || required.include?(name)
109
+ end
110
+
111
+ def build_tool_worker(tool_def, credentials:, domain:)
112
+ credentials = tool_def.credentials + credentials
113
+ Worker::Worker.new(
114
+ tool_def.name,
115
+ ->(task) { Dispatch.run_tool_task(task, tool_def, logger: @logger) },
116
+ **worker_options(domain, @agent_config.worker_thread_count),
117
+ task_def_template: task_def_for(tool_def.name, retry_count: tool_def.retry_count,
118
+ retry_delay_seconds: tool_def.retry_delay_seconds,
119
+ retry_logic: tool_def.retry_logic, credentials: credentials)
120
+ )
121
+ end
122
+
123
+ def each_agent_with_credentials(agent, inherited = [], &block)
124
+ credentials = (inherited + agent.credentials).uniq
125
+ yield agent, credentials
126
+ children = agent.agents + [agent.router, agent.planner, agent.fallback].grep(Agent)
127
+ children += agent.tools.filter_map { |tool| tool.config['agent'] if tool.tool_type == ToolType::AGENT_TOOL }.grep(Agent)
128
+ children.each { |child| each_agent_with_credentials(child, credentials, &block) }
129
+ end
130
+
131
+ def build_system_worker(name, body, domain, agent)
132
+ Worker::Worker.new(
133
+ name, body,
134
+ **worker_options(domain, @agent_config.system_worker_thread_count),
135
+ task_def_template: task_def_for(name, credentials: agent.credentials)
136
+ )
137
+ end
138
+
139
+ # When a run has a runId the server maps every required worker to that domain, so all
140
+ # workers of the run poll on it.
141
+ def worker_options(domain, thread_count)
142
+ {
143
+ register_task_def: true,
144
+ overwrite_task_def: true,
145
+ lease_extend_enabled: true,
146
+ poll_interval: @agent_config.worker_poll_interval_ms,
147
+ thread_count: thread_count,
148
+ domain: domain
149
+ }
150
+ end
151
+
152
+ def warn_unhandled(required, workers)
153
+ return if required.nil?
154
+
155
+ handled = workers.map(&:task_definition_name)
156
+ missing = required.to_a - handled
157
+ return if missing.empty?
158
+
159
+ @logger&.warn("server requires workers this process does not provide: #{missing.join(', ')} " \
160
+ '(tasks of these types will stay SCHEDULED until some worker serves them)')
161
+ end
162
+ end
163
+ end
164
+ end