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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/examples/agents/01_basic_agent.rb +36 -0
- data/examples/agents/02a_simple_tools.rb +47 -0
- data/examples/agents/02c_tool_retry_config.rb +52 -0
- data/examples/agents/04_http_and_mcp_tools.rb +58 -0
- data/examples/agents/05_handoffs.rb +71 -0
- data/examples/agents/06_sequential_pipeline.rb +47 -0
- data/examples/agents/07_parallel_agents.rb +52 -0
- data/examples/agents/09_human_in_the_loop.rb +59 -0
- data/examples/agents/09c_hitl_streaming.rb +65 -0
- data/examples/agents/103_plan_and_compile.rb +85 -0
- data/examples/agents/10_guardrails.rb +61 -0
- data/examples/agents/13_hierarchical_agents.rb +79 -0
- data/examples/agents/16e_credentials_http_tool.rb +44 -0
- data/examples/agents/17_swarm_orchestration.rb +56 -0
- data/examples/agents/21_regex_guardrails.rb +69 -0
- data/examples/agents/22_llm_guardrails.rb +51 -0
- data/examples/agents/33_external_workers.rb +65 -0
- data/examples/agents/64_swarm_with_tools.rb +71 -0
- data/examples/agents/66_handoff_to_parallel.rb +62 -0
- data/examples/agents/bug_desk.rb +50 -0
- data/examples/agents/catalog.rb +32 -0
- data/examples/agents/dump_agent_configs.rb +30 -0
- data/examples/agents/external_workers.rb +39 -0
- data/examples/agents/golden_agents.rb +385 -0
- data/examples/agents/support_approval.rb +51 -0
- data/examples/agents/weather.rb +27 -0
- data/lib/conductor/agents/agent.rb +324 -0
- data/lib/conductor/agents/callback_handler.rb +64 -0
- data/lib/conductor/agents/config_serializer.rb +246 -0
- data/lib/conductor/agents/errors.rb +26 -0
- data/lib/conductor/agents/guardrail.rb +142 -0
- data/lib/conductor/agents/handoff.rb +94 -0
- data/lib/conductor/agents/memory.rb +75 -0
- data/lib/conductor/agents/plans.rb +22 -0
- data/lib/conductor/agents/prompt_template.rb +23 -0
- data/lib/conductor/agents/runtime/agent_config.rb +52 -0
- data/lib/conductor/agents/runtime/agent_runtime.rb +260 -0
- data/lib/conductor/agents/runtime/approval_request.rb +111 -0
- data/lib/conductor/agents/runtime/dispatch.rb +137 -0
- data/lib/conductor/agents/runtime/execution.rb +265 -0
- data/lib/conductor/agents/runtime/secrets.rb +54 -0
- data/lib/conductor/agents/runtime/sse_client.rb +175 -0
- data/lib/conductor/agents/runtime/status_poller.rb +49 -0
- data/lib/conductor/agents/runtime/system_workers.rb +115 -0
- data/lib/conductor/agents/runtime/tool_call.rb +13 -0
- data/lib/conductor/agents/runtime/tool_registry.rb +164 -0
- data/lib/conductor/agents/termination.rb +192 -0
- data/lib/conductor/agents/tool.rb +283 -0
- data/lib/conductor/agents/tools/schema_builder.rb +190 -0
- data/lib/conductor/agents/tools/secret_scanner.rb +45 -0
- data/lib/conductor/agents/tools.rb +173 -0
- data/lib/conductor/agents.rb +75 -0
- data/lib/conductor/client/agent_client.rb +117 -0
- data/lib/conductor/client/task_client.rb +7 -0
- data/lib/conductor/configuration.rb +43 -10
- data/lib/conductor/exceptions.rb +36 -0
- data/lib/conductor/http/api/agent_resource_api.rb +138 -0
- data/lib/conductor/http/api/scheduler_resource_api.rb +31 -12
- data/lib/conductor/http/api/task_resource_api.rb +15 -0
- data/lib/conductor/http/models/task.rb +10 -2
- data/lib/conductor/http/models/task_def.rb +14 -3
- data/lib/conductor/http/rest_client.rb +9 -0
- data/lib/conductor/orkes/orkes_clients.rb +5 -1
- data/lib/conductor/version.rb +1 -1
- data/lib/conductor/worker/lease_renewer.rb +55 -0
- data/lib/conductor/worker/ractor_task_runner.rb +1 -1
- data/lib/conductor/worker/task_definition_registrar.rb +2 -2
- data/lib/conductor/worker/task_runner.rb +43 -10
- data/lib/conductor/worker/worker.rb +3 -2
- data/lib/conductor/worker/worker_config.rb +2 -1
- data/lib/conductor.rb +3 -1
- 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
|