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,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
|