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,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module Conductor
6
+ module Agents
7
+ # Result of a guardrail check
8
+ GuardrailResult = Struct.new(:passed, :message, :fixed_output, keyword_init: true) do
9
+ def initialize(passed:, message: '', fixed_output: nil)
10
+ super
11
+ end
12
+
13
+ def passed?
14
+ passed ? true : false
15
+ end
16
+ end
17
+
18
+ # Validation applied to an agent's input or output.
19
+ #
20
+ # Guardrail.new(name: 'no_pii', position: :output, on_fail: :retry) do |content|
21
+ # content =~ SSN ? GuardrailResult.new(passed: false, message: 'Redact it') : GuardrailResult.new(passed: true)
22
+ # end
23
+ #
24
+ # A guardrail with a block runs as a worker in this process (guardrailType "custom");
25
+ # one with only a name references a worker running elsewhere ("external").
26
+ class Guardrail
27
+ POSITIONS = %w[input output].freeze
28
+ ON_FAIL = %w[retry raise fix human].freeze
29
+
30
+ attr_reader :name, :position, :on_fail, :max_retries, :func
31
+
32
+ # @param name [String, nil] required when no block is given
33
+ # @param position [Symbol, String] :input or :output
34
+ # @param on_fail [Symbol, String] :retry, :raise, :fix or :human
35
+ def initialize(name: nil, position: :output, on_fail: :raise, max_retries: 3, func: nil, &block)
36
+ @position = position.to_s
37
+ @on_fail = on_fail.to_s
38
+ raise ConfigurationError, "invalid position #{position.inspect}; use :input or :output" unless POSITIONS.include?(@position)
39
+ raise ConfigurationError, "invalid on_fail #{on_fail.inspect}; use one of #{ON_FAIL.join(', ')}" unless ON_FAIL.include?(@on_fail)
40
+ raise ConfigurationError, 'on_fail: :human is only valid for position: :output' if @on_fail == 'human' && @position == 'input'
41
+ raise ConfigurationError, 'max_retries must be >= 1' if max_retries.to_i < 1
42
+
43
+ @func = func || block
44
+ raise ConfigurationError, 'a guardrail needs a name or a block' if @func.nil? && name.nil?
45
+
46
+ @name = (name || 'guardrail').to_s
47
+ @max_retries = max_retries.to_i
48
+ end
49
+
50
+ # True when the check runs somewhere else (no local implementation)
51
+ def external?
52
+ @func.nil?
53
+ end
54
+
55
+ # @param content [String]
56
+ # @return [GuardrailResult]
57
+ def check(content)
58
+ raise Error, "cannot check external guardrail #{@name.inspect} locally" if external?
59
+
60
+ result = @func.call(content)
61
+ return result if result.is_a?(GuardrailResult)
62
+ return GuardrailResult.new(passed: result) if [true, false].include?(result)
63
+
64
+ raise Error, "guardrail #{@name.inspect} must return a GuardrailResult or true/false, got #{result.class}"
65
+ end
66
+
67
+ # Wire discriminator (see ConfigSerializer)
68
+ def guardrail_type
69
+ external? ? 'external' : 'custom'
70
+ end
71
+
72
+ def to_s
73
+ "#<#{self.class.name.split('::').last} #{@name} position=#{@position} on_fail=#{@on_fail}>"
74
+ end
75
+ alias inspect to_s
76
+ end
77
+
78
+ # Reject content that matches (mode :block) or fails to match (mode :allow) regex patterns.
79
+ class RegexGuardrail < Guardrail
80
+ MODES = %w[block allow].freeze
81
+
82
+ attr_reader :pattern_strings, :mode, :message
83
+
84
+ # @param patterns [String, Regexp, Array<String, Regexp>]
85
+ def initialize(patterns, mode: :block, position: :output, on_fail: :raise, name: 'regex_guardrail',
86
+ message: nil, max_retries: 3)
87
+ @mode = mode.to_s
88
+ raise ConfigurationError, "invalid mode #{mode.inspect}; use :block or :allow" unless MODES.include?(@mode)
89
+
90
+ @pattern_strings = Array(patterns).map { |p| p.is_a?(Regexp) ? p.source : p.to_s }
91
+ @patterns = @pattern_strings.map { |p| Regexp.new(p) }
92
+ @message = message
93
+ super(name: name, position: position, on_fail: on_fail, max_retries: max_retries, func: method(:evaluate))
94
+ end
95
+
96
+ def guardrail_type
97
+ 'regex'
98
+ end
99
+
100
+ private
101
+
102
+ def evaluate(content)
103
+ text = content.to_s
104
+ matched = @patterns.any? { |p| p.match?(text) }
105
+ if @mode == 'block' && matched
106
+ GuardrailResult.new(passed: false, message: @message || 'Content matched a blocked pattern.')
107
+ elsif @mode == 'allow' && !matched
108
+ GuardrailResult.new(passed: false, message: @message || 'Content did not match any allowed pattern.')
109
+ else
110
+ GuardrailResult.new(passed: true)
111
+ end
112
+ end
113
+ end
114
+
115
+ # Ask an LLM (on the server) whether content complies with a policy.
116
+ class LlmGuardrail < Guardrail
117
+ attr_reader :model, :policy, :max_tokens
118
+
119
+ # @param model [String] "provider/model"
120
+ def initialize(model, policy, position: :output, on_fail: :raise, name: 'llm_guardrail', max_retries: 3,
121
+ max_tokens: nil)
122
+ raise ConfigurationError, 'LlmGuardrail needs a model in "provider/model" form' unless model.to_s.include?('/')
123
+
124
+ @model = model
125
+ @policy = policy
126
+ @max_tokens = max_tokens
127
+ super(name: name, position: position, on_fail: on_fail, max_retries: max_retries, func: method(:evaluate))
128
+ end
129
+
130
+ def guardrail_type
131
+ 'llm'
132
+ end
133
+
134
+ private
135
+
136
+ # The server compiles this guardrail into an LLM task; there is no local evaluation.
137
+ def evaluate(_content)
138
+ GuardrailResult.new(passed: false, message: 'LlmGuardrail is evaluated by the Conductor server')
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module Conductor
6
+ module Agents
7
+ # Rules that transfer control between agents in a team (swarm orchestration).
8
+ #
9
+ # Handoff::OnTextMention.new(target: 'filer', text: 'ACTIONABLE')
10
+ # Handoff::OnToolResult.new(target: 'refund', tool_name: 'check_order')
11
+ # Handoff::OnCondition.new(target: 'summarizer') { |ctx| ctx['iteration'].to_i > 5 }
12
+ module Handoff
13
+ # Base class. +target+ is the receiving agent's name (an Agent is accepted too).
14
+ class Condition
15
+ attr_reader :target
16
+
17
+ def initialize(target:)
18
+ @target = target.respond_to?(:name) ? target.name.to_s : target.to_s
19
+ raise ConfigurationError, 'handoff target is required' if @target.empty?
20
+ end
21
+
22
+ # @param _context [Hash] result, tool_name, tool_result, messages, iteration
23
+ def should_handoff(_context)
24
+ false
25
+ end
26
+
27
+ def to_s
28
+ "#<#{self.class.name.split('::').last} -> #{@target}>"
29
+ end
30
+ alias inspect to_s
31
+
32
+ protected
33
+
34
+ def ctx(context, key)
35
+ return nil unless context.respond_to?(:key?)
36
+
37
+ context.key?(key.to_s) ? context[key.to_s] : context[key.to_sym]
38
+ end
39
+ end
40
+
41
+ # After a named tool ran (optionally only when its result contains a substring)
42
+ class OnToolResult < Condition
43
+ attr_reader :tool_name, :result_contains
44
+
45
+ def initialize(target:, tool_name:, result_contains: nil)
46
+ @tool_name = tool_name.to_s
47
+ @result_contains = result_contains
48
+ super(target: target)
49
+ end
50
+
51
+ def should_handoff(context)
52
+ return false unless ctx(context, :tool_name).to_s == @tool_name
53
+ return true if @result_contains.nil?
54
+
55
+ ctx(context, :tool_result).to_s.include?(@result_contains.to_s)
56
+ end
57
+ end
58
+
59
+ # When the output mentions +text+ (case-insensitive)
60
+ class OnTextMention < Condition
61
+ attr_reader :text
62
+
63
+ def initialize(target:, text:)
64
+ @text = text.to_s
65
+ raise ConfigurationError, 'text is required' if @text.empty?
66
+
67
+ super(target: target)
68
+ end
69
+
70
+ def should_handoff(context)
71
+ ctx(context, :result).to_s.downcase.include?(@text.downcase)
72
+ end
73
+ end
74
+
75
+ # When a block returns true; runs as the <agent>_handoff_<target> worker
76
+ class OnCondition < Condition
77
+ attr_reader :condition
78
+
79
+ def initialize(target:, condition: nil, &block)
80
+ @condition = condition || block
81
+ raise ConfigurationError, 'OnCondition needs a block' if @condition.nil?
82
+
83
+ super(target: target)
84
+ end
85
+
86
+ def should_handoff(context)
87
+ @condition.call(context) ? true : false
88
+ rescue StandardError
89
+ false
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Conductor
4
+ module Agents
5
+ # Conversation history seeded into the agent (memory: on Agent). Messages are
6
+ # prepended to the LLM conversation by the server; max_messages trims the oldest
7
+ # non-system messages first.
8
+ class ConversationMemory
9
+ attr_reader :messages, :max_messages
10
+
11
+ def initialize(messages: [], max_messages: nil)
12
+ @messages = messages.map { |m| m.transform_keys(&:to_s) }
13
+ @max_messages = max_messages
14
+ trim
15
+ end
16
+
17
+ def add_user_message(content)
18
+ push('role' => 'user', 'message' => content.to_s)
19
+ end
20
+
21
+ def add_assistant_message(content)
22
+ push('role' => 'assistant', 'message' => content.to_s)
23
+ end
24
+
25
+ def add_system_message(content)
26
+ push('role' => 'system', 'message' => content.to_s)
27
+ end
28
+
29
+ def add_tool_call(tool_name, arguments, task_reference_name: nil)
30
+ ref = task_reference_name || "#{tool_name}_ref"
31
+ push('role' => 'tool_call', 'message' => '',
32
+ 'tool_calls' => [{ 'name' => tool_name.to_s, 'taskReferenceName' => ref, 'input' => arguments }])
33
+ end
34
+
35
+ def add_tool_result(tool_name, result, task_reference_name: nil)
36
+ ref = task_reference_name || "#{tool_name}_ref"
37
+ push('role' => 'tool', 'message' => result.to_s, 'toolCallId' => ref, 'taskReferenceName' => ref)
38
+ end
39
+
40
+ # Deep copy of the messages
41
+ def to_chat_messages
42
+ Marshal.load(Marshal.dump(@messages))
43
+ end
44
+
45
+ def clear
46
+ @messages.clear
47
+ end
48
+
49
+ def empty?
50
+ @messages.empty?
51
+ end
52
+
53
+ private
54
+
55
+ def push(message)
56
+ @messages << message
57
+ trim
58
+ end
59
+
60
+ def trim
61
+ return unless @max_messages && @messages.size > @max_messages
62
+
63
+ system_msgs, others = @messages.partition { |m| m['role'] == 'system' }
64
+ if system_msgs.size >= @max_messages
65
+ @messages = system_msgs.last(@max_messages)
66
+ return
67
+ end
68
+
69
+ keep = @max_messages - system_msgs.size
70
+ dropped = others.first(others.size - keep)
71
+ @messages = @messages.reject { |m| dropped.any? { |d| d.equal?(m) } }
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'agent'
4
+
5
+ module Conductor
6
+ module Agents
7
+ # Build the server-side planner, optional recovery agent, and coordinator.
8
+ module Plans
9
+ def plan_execute(name:, tools:, model:, planner_instructions: '', fallback_instructions: nil,
10
+ fallback_max_turns: nil, planner_context: [])
11
+ planner = Agent.new(name: "#{name}_planner", model: model, instructions: planner_instructions)
12
+ unless fallback_instructions.nil? || fallback_instructions.empty?
13
+ fallback = Agent.new(name: "#{name}_fallback", model: model,
14
+ instructions: fallback_instructions, tools: tools)
15
+ end
16
+ Agent.new(name: name, model: model, strategy: :plan_execute, tools: tools,
17
+ planner: planner, fallback: fallback, fallback_max_turns: fallback_max_turns,
18
+ planner_context: planner_context)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Conductor
4
+ module Agents
5
+ # Reference to a prompt template stored on the server, used as Agent#instructions.
6
+ class PromptTemplate
7
+ attr_reader :name, :variables, :version
8
+
9
+ def initialize(name:, variables: {}, version: nil)
10
+ @name = name.to_s
11
+ @variables = variables || {}
12
+ @version = version
13
+ end
14
+
15
+ def to_h
16
+ h = { 'type' => 'prompt_template', 'name' => @name }
17
+ h['variables'] = @variables unless @variables.empty?
18
+ h['version'] = @version unless @version.nil?
19
+ h
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Conductor
4
+ module Agents
5
+ # Runtime knobs, read from CONDUCTOR_AGENT_* environment variables (same names and
6
+ # defaults as the Python SDK's AgentConfig).
7
+ class AgentConfig
8
+ TRUE_VALUES = %w[true 1 yes on].freeze
9
+ FALSE_VALUES = %w[false 0 no off].freeze
10
+
11
+ attr_accessor :worker_poll_interval_ms, :worker_thread_count, :auto_register_integrations,
12
+ :streaming_enabled, :status_poll_interval_seconds, :system_worker_thread_count
13
+
14
+ def initialize(worker_poll_interval_ms: 100, worker_thread_count: 1, auto_register_integrations: false,
15
+ streaming_enabled: true, status_poll_interval_seconds: 0.5, system_worker_thread_count: 10)
16
+ @worker_poll_interval_ms = worker_poll_interval_ms
17
+ @worker_thread_count = worker_thread_count
18
+ @auto_register_integrations = auto_register_integrations
19
+ @streaming_enabled = streaming_enabled
20
+ @status_poll_interval_seconds = status_poll_interval_seconds
21
+ @system_worker_thread_count = system_worker_thread_count
22
+ end
23
+
24
+ # @param env [Hash] defaults to ENV
25
+ def self.from_env(env = ENV)
26
+ new(
27
+ worker_poll_interval_ms: int(env, 'CONDUCTOR_AGENT_WORKER_POLL_INTERVAL', 100),
28
+ worker_thread_count: int(env, 'CONDUCTOR_AGENT_WORKER_THREADS', 1),
29
+ auto_register_integrations: bool(env, 'CONDUCTOR_AGENT_INTEGRATIONS_AUTO_REGISTER', false),
30
+ streaming_enabled: bool(env, 'CONDUCTOR_AGENT_STREAMING_ENABLED', true)
31
+ )
32
+ end
33
+
34
+ def self.int(env, key, default)
35
+ raw = env[key].to_s.strip
36
+ raw.empty? ? default : Integer(raw, 10)
37
+ rescue ArgumentError
38
+ default
39
+ end
40
+
41
+ def self.bool(env, key, default)
42
+ raw = env[key].to_s.strip.downcase
43
+ return default if raw.empty?
44
+ return true if TRUE_VALUES.include?(raw)
45
+ return false if FALSE_VALUES.include?(raw)
46
+
47
+ default
48
+ end
49
+ private_class_method :int, :bool
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'logger'
5
+ require 'set'
6
+ require_relative '../errors'
7
+ require_relative '../config_serializer'
8
+ require_relative 'agent_config'
9
+ require_relative 'execution'
10
+ require_relative 'approval_request'
11
+ require_relative 'sse_client'
12
+ require_relative 'status_poller'
13
+ require_relative 'tool_registry'
14
+ require_relative '../../client/agent_client'
15
+ require_relative '../../worker/task_handler'
16
+
17
+ module Conductor
18
+ module Agents
19
+ # Runs agents against a Conductor server: serializes the agentConfig, starts the
20
+ # execution, registers the workers the server asks for, and streams the result.
21
+ #
22
+ # runtime = Conductor::Agents::AgentRuntime.new(configuration: Conductor::Configuration.new)
23
+ # runtime.call_sync(agent, 'Weather in Lisbon?')
24
+ #
25
+ # Conductor::Agents.runtime holds a default instance built from the environment;
26
+ # Agent#call_sync / #call_async use it.
27
+ class AgentRuntime
28
+ attr_reader :configuration, :agent_config, :client, :api_client, :logger
29
+
30
+ def initialize(configuration: nil, agent_config: nil, logger: nil, api_client: nil, agent_client: nil)
31
+ @configuration = configuration || Configuration.new
32
+ @agent_config = agent_config || AgentConfig.from_env
33
+ @logger = logger || Logger.new($stdout, level: Logger::INFO, progname: 'conductor-agents')
34
+ @api_client = api_client || Http::ApiClient.new(configuration: @configuration)
35
+ @client = agent_client || Client::AgentClient.new(@api_client)
36
+ @registry = ToolRegistry.new(@agent_config, logger: @logger)
37
+ @handlers = []
38
+ @running_workers = Set.new
39
+ @stream_threads = []
40
+ @mutex = Mutex.new
41
+ end
42
+
43
+ # Run and wait for the answer
44
+ # @return [String]
45
+ def call_sync(agent, prompt, session_id: nil, timeout: nil, **options)
46
+ call_async(agent, prompt, session_id: session_id, **options).result(timeout: timeout)
47
+ end
48
+
49
+ # Start the agent and return immediately with an Execution
50
+ # @param session_id [String, nil] conversation id to continue
51
+ # @param media [Array<String>, nil], context [Hash, nil], idempotency_key [String, nil], timeout_seconds [Integer, nil]
52
+ # @yield [answer, execution] runs on the stream thread when the execution finishes
53
+ # @return [Execution]
54
+ def call_async(agent, prompt, session_id: nil, media: nil, context: nil, idempotency_key: nil,
55
+ timeout_seconds: nil, on_event: nil, &on_done)
56
+ payload = start_payload(agent, prompt, session_id: session_id, media: media, context: context,
57
+ idempotency_key: idempotency_key, timeout_seconds: timeout_seconds)
58
+ response = @client.start_agent(payload)
59
+ execution_id = response['executionId'] || raise(Error, "server returned no executionId: #{response.inspect}")
60
+
61
+ execution = Execution.new(execution_id, client: @client, agent_name: response['agentName'] || agent.name, runtime: self)
62
+ start_workers(agent, response['requiredWorkers'], domain: payload['runId'])
63
+ attach(execution, agent: agent, on_event: on_event, &on_done)
64
+ execution
65
+ end
66
+
67
+ # Register agents on the server without running them
68
+ # @return [Array<String>] deployed agent names
69
+ def deploy(*agents)
70
+ agents.flatten.map do |agent|
71
+ response = @client.deploy_agent('agentConfig' => ConfigSerializer.serialize(agent))
72
+ response['agentName'] || agent.name
73
+ end
74
+ end
75
+
76
+ # Compile without registering: { "workflowDef", "requiredWorkers" }
77
+ def compile(agent)
78
+ @client.compile_agent('agentConfig' => ConfigSerializer.serialize(agent))
79
+ end
80
+
81
+ # Deploy, start workers for every tool, and (by default) block until INT/TERM
82
+ def serve(*agents, blocking: true)
83
+ agents = agents.flatten
84
+ agents.each do |agent|
85
+ response = @client.deploy_agent('agentConfig' => ConfigSerializer.serialize(agent))
86
+ start_workers(agent, response['requiredWorkers'], domain: nil)
87
+ end
88
+ return self unless blocking
89
+
90
+ wait_for_signal
91
+ shutdown
92
+ self
93
+ end
94
+
95
+ # Follow an execution on a background thread (used by call_async and Execution#result)
96
+ def attach(execution, agent: nil, on_event: nil, &on_done)
97
+ execution.attached!
98
+ thread = Thread.new do
99
+ Thread.current.name = "conductor-agent-stream-#{execution.execution_id}"
100
+ follow(execution, agent, on_event: on_event, &on_done)
101
+ end
102
+ @mutex.synchronize { @stream_threads << thread }
103
+ thread
104
+ end
105
+
106
+ # Stop workers and stream threads
107
+ def shutdown(timeout: 5)
108
+ handlers, threads = @mutex.synchronize do
109
+ h = @handlers.dup
110
+ t = @stream_threads.dup
111
+ @handlers.clear
112
+ @stream_threads.clear
113
+ @running_workers.clear
114
+ [h, t]
115
+ end
116
+ handlers.each { |h| h.stop(timeout: timeout) }
117
+ threads.each do |t|
118
+ t.join(timeout)
119
+ t.kill if t.alive?
120
+ end
121
+ self
122
+ end
123
+
124
+ # Names of the workers currently polling
125
+ def running_workers
126
+ @mutex.synchronize { @running_workers.map(&:first) }
127
+ end
128
+
129
+ # Build the AgentStartRequest body
130
+ def start_payload(agent, prompt, session_id: nil, media: nil, context: nil, idempotency_key: nil, timeout_seconds: nil)
131
+ payload = {
132
+ 'agentConfig' => ConfigSerializer.serialize(agent),
133
+ 'prompt' => prompt.to_s,
134
+ 'sessionId' => session_id.to_s,
135
+ 'media' => Array(media)
136
+ }
137
+ payload['context'] = context if context && !context.empty?
138
+ payload['idempotencyKey'] = idempotency_key if idempotency_key
139
+ payload['timeoutSeconds'] = timeout_seconds if timeout_seconds
140
+ payload['runId'] = SecureRandom.hex(16) if agent.stateful_tree?
141
+ payload
142
+ end
143
+
144
+ private
145
+
146
+ # Start workers for the tools and system tasks the server requires (skipping ones already polling)
147
+ def start_workers(agent, required_workers, domain:)
148
+ workers = @registry.workers_for(agent, required_workers: required_workers, domain: domain)
149
+ fresh = @mutex.synchronize do
150
+ workers.reject { |w| @running_workers.include?([w.task_definition_name, w.domain]) }
151
+ .each { |w| @running_workers << [w.task_definition_name, w.domain] }
152
+ end
153
+ return if fresh.empty?
154
+
155
+ handler = Worker::TaskHandler.new(workers: fresh, configuration: @configuration, logger: @logger,
156
+ scan_for_annotated_workers: false, register_task_definitions: true)
157
+ handler.start
158
+ @mutex.synchronize { @handlers << handler }
159
+ @logger.info("agent workers started: #{fresh.map(&:task_definition_name).join(', ')}")
160
+ end
161
+
162
+ def follow(execution, agent, on_event: nil, &on_done)
163
+ events = event_source(execution.execution_id)
164
+ events.each do |event|
165
+ handle_event(execution, agent, event)
166
+ run_callback(on_event, event) if on_event
167
+ break if execution.done?
168
+ end
169
+ execution.fail('stream ended before the execution finished') unless execution.done?
170
+ rescue StandardError => e
171
+ @logger.error("stream for #{execution.execution_id} failed: #{e.class}: #{e.message}")
172
+ execution.fail("#{e.class}: #{e.message}") unless execution.done?
173
+ ensure
174
+ run_callback(on_done, execution.answer, execution) if on_done
175
+ end
176
+
177
+ def event_source(execution_id)
178
+ poller = StatusPoller.new(@client, interval: @agent_config.status_poll_interval_seconds, logger: @logger)
179
+ return poller.each_event(execution_id) unless @agent_config.streaming_enabled
180
+
181
+ sse = SseClient.new(@api_client, logger: @logger)
182
+ Enumerator.new do |y|
183
+ sse.each_event(execution_id) { |ev| y << ev }
184
+ rescue SseUnavailableError => e
185
+ @logger.info("SSE unavailable (#{e.message}); polling status instead")
186
+ poller.each_event(execution_id) { |ev| y << ev }
187
+ end
188
+ end
189
+
190
+ def handle_event(execution, agent, event)
191
+ data = event['data'] || {}
192
+ execution.record_event(event)
193
+ case event['event'].to_s
194
+ when 'message'
195
+ execution.append_text(data['content'])
196
+ when 'tool_call'
197
+ execution.add_tool_call(data['toolName'], strip_injected(data['args']))
198
+ when 'tool_result'
199
+ execution.add_tool_result(data['toolName'], data['result'])
200
+ when 'waiting'
201
+ handle_waiting(execution, agent, data)
202
+ when 'done'
203
+ execution.token_usage = fetch_token_usage(execution.execution_id)
204
+ execution.finish(status: 'COMPLETED', output: data['output'] || {})
205
+ when 'error'
206
+ execution.finish(status: data['status'] || 'FAILED', output: data['output'] || {},
207
+ reason: data['content'] || 'execution failed')
208
+ end
209
+ end
210
+
211
+ def handle_waiting(execution, agent, data)
212
+ pending = data['pendingTool'] || {}
213
+ request = ApprovalRequest.new(execution.execution_id, pending, client: @client, execution: execution)
214
+ execution.mark_waiting(request)
215
+ handler = agent&.approval_handler
216
+ return if handler.nil? || request.tool_calls.empty?
217
+
218
+ run_callback(handler, request)
219
+ end
220
+
221
+ def run_callback(callable, *args)
222
+ callable.call(*args)
223
+ rescue StandardError => e
224
+ @logger.error("callback raised #{e.class}: #{e.message}")
225
+ end
226
+
227
+ def strip_injected(args)
228
+ return {} unless args.is_a?(Hash)
229
+
230
+ args.reject { |k, _| Dispatch::INJECTED_KEYS.include?(k.to_s) }
231
+ end
232
+
233
+ # Sum tokenUsage over the execution and its sub-agent executions
234
+ def fetch_token_usage(execution_id, visited = Set.new)
235
+ return TokenUsage.new if visited.include?(execution_id) || visited.size > 50
236
+
237
+ visited << execution_id
238
+ run = @client.get_execution(execution_id)
239
+ usage = run['tokenUsage'] || {}
240
+ total = TokenUsage.new(prompt_tokens: usage['promptTokens'].to_i, completion_tokens: usage['completionTokens'].to_i,
241
+ total_tokens: usage['totalTokens'].to_i)
242
+ Array(run['tasks']).each do |task|
243
+ sub = task['subWorkflowId']
244
+ total += fetch_token_usage(sub, visited) if sub && !sub.to_s.empty?
245
+ end
246
+ total
247
+ rescue StandardError => e
248
+ @logger.debug("token usage unavailable for #{execution_id}: #{e.message}")
249
+ TokenUsage.new
250
+ end
251
+
252
+ def wait_for_signal
253
+ queue = Queue.new
254
+ %w[INT TERM].each { |sig| trap(sig) { queue << sig } }
255
+ @logger.info('serving agents; press Ctrl-C to stop')
256
+ queue.pop
257
+ end
258
+ end
259
+ end
260
+ end