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,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module Conductor
|
|
6
|
+
module Agents
|
|
7
|
+
# Composable rules that decide when an agent loop stops.
|
|
8
|
+
#
|
|
9
|
+
# stop = Termination::TextMention.new('DONE') | Termination::MaxMessage.new(20)
|
|
10
|
+
# Agent.new(..., termination: stop)
|
|
11
|
+
#
|
|
12
|
+
# Conditions serialize to the server (ConfigSerializer) and the same objects back the
|
|
13
|
+
# local <agent>_termination worker the server asks for.
|
|
14
|
+
module Termination
|
|
15
|
+
Result = Struct.new(:should_terminate, :reason, keyword_init: true) do
|
|
16
|
+
def initialize(should_terminate:, reason: '')
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Base class. Context keys: result, messages, iteration, token_usage (String or Symbol keys).
|
|
22
|
+
class Condition
|
|
23
|
+
def should_terminate(_context)
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def &(other)
|
|
28
|
+
And.new(self, other)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def |(other)
|
|
32
|
+
Or.new(self, other)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_s
|
|
36
|
+
"#<#{self.class.name.split('::').last}>"
|
|
37
|
+
end
|
|
38
|
+
alias inspect to_s
|
|
39
|
+
|
|
40
|
+
protected
|
|
41
|
+
|
|
42
|
+
def ctx(context, key)
|
|
43
|
+
return nil unless context.respond_to?(:key?)
|
|
44
|
+
|
|
45
|
+
context.key?(key.to_s) ? context[key.to_s] : context[key.to_sym]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Stop when the output contains +text+
|
|
50
|
+
class TextMention < Condition
|
|
51
|
+
attr_reader :text, :case_sensitive
|
|
52
|
+
|
|
53
|
+
def initialize(text, case_sensitive: false)
|
|
54
|
+
raise ConfigurationError, 'text is required' if text.to_s.empty?
|
|
55
|
+
|
|
56
|
+
@text = text.to_s
|
|
57
|
+
@case_sensitive = case_sensitive ? true : false
|
|
58
|
+
super()
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def should_terminate(context)
|
|
62
|
+
result = ctx(context, :result).to_s
|
|
63
|
+
needle = @text
|
|
64
|
+
unless @case_sensitive
|
|
65
|
+
result = result.downcase
|
|
66
|
+
needle = needle.downcase
|
|
67
|
+
end
|
|
68
|
+
return Result.new(should_terminate: true, reason: "Text '#{@text}' found in output") if result.include?(needle)
|
|
69
|
+
|
|
70
|
+
Result.new(should_terminate: false)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Stop when the whole output (stripped) equals +stop_message+
|
|
75
|
+
class StopMessage < Condition
|
|
76
|
+
attr_reader :stop_message
|
|
77
|
+
|
|
78
|
+
def initialize(stop_message = 'TERMINATE')
|
|
79
|
+
@stop_message = stop_message.to_s
|
|
80
|
+
super()
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def should_terminate(context)
|
|
84
|
+
return Result.new(should_terminate: true, reason: "Stop message '#{@stop_message}' received") if ctx(context, :result).to_s.strip == @stop_message
|
|
85
|
+
|
|
86
|
+
Result.new(should_terminate: false)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Stop after +max_messages+ messages (falls back to the loop iteration count)
|
|
91
|
+
class MaxMessage < Condition
|
|
92
|
+
attr_reader :max_messages
|
|
93
|
+
|
|
94
|
+
def initialize(max_messages)
|
|
95
|
+
raise ConfigurationError, 'max_messages must be >= 1' unless max_messages.is_a?(Integer) && max_messages >= 1
|
|
96
|
+
|
|
97
|
+
@max_messages = max_messages
|
|
98
|
+
super()
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def should_terminate(context)
|
|
102
|
+
messages = ctx(context, :messages)
|
|
103
|
+
count = messages.is_a?(Array) ? messages.size : 0
|
|
104
|
+
count = ctx(context, :iteration).to_i if count.zero?
|
|
105
|
+
return Result.new(should_terminate: true, reason: "Message count (#{count}) >= limit (#{@max_messages})") if count >= @max_messages
|
|
106
|
+
|
|
107
|
+
Result.new(should_terminate: false)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Stop when token usage crosses a budget
|
|
112
|
+
class TokenUsage < Condition
|
|
113
|
+
attr_reader :max_total_tokens, :max_prompt_tokens, :max_completion_tokens
|
|
114
|
+
|
|
115
|
+
def initialize(max_total_tokens: nil, max_prompt_tokens: nil, max_completion_tokens: nil)
|
|
116
|
+
raise ConfigurationError, 'at least one token limit must be specified' if [max_total_tokens, max_prompt_tokens, max_completion_tokens].all?(&:nil?)
|
|
117
|
+
|
|
118
|
+
@max_total_tokens = max_total_tokens
|
|
119
|
+
@max_prompt_tokens = max_prompt_tokens
|
|
120
|
+
@max_completion_tokens = max_completion_tokens
|
|
121
|
+
super()
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def should_terminate(context)
|
|
125
|
+
usage = ctx(context, :token_usage)
|
|
126
|
+
return Result.new(should_terminate: false) unless usage.respond_to?(:key?)
|
|
127
|
+
|
|
128
|
+
checks = [
|
|
129
|
+
[@max_total_tokens, usage_value(usage, 'total_tokens', 'totalTokens'), 'Total'],
|
|
130
|
+
[@max_prompt_tokens, usage_value(usage, 'prompt_tokens', 'promptTokens'), 'Prompt'],
|
|
131
|
+
[@max_completion_tokens, usage_value(usage, 'completion_tokens', 'completionTokens'), 'Completion']
|
|
132
|
+
]
|
|
133
|
+
checks.each do |limit, value, label|
|
|
134
|
+
next if limit.nil? || value < limit
|
|
135
|
+
|
|
136
|
+
return Result.new(should_terminate: true, reason: "#{label} tokens (#{value}) >= limit (#{limit})")
|
|
137
|
+
end
|
|
138
|
+
Result.new(should_terminate: false)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def usage_value(usage, *keys)
|
|
144
|
+
keys.each do |k|
|
|
145
|
+
v = usage[k] || usage[k.to_sym]
|
|
146
|
+
return v.to_i unless v.nil?
|
|
147
|
+
end
|
|
148
|
+
0
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# All children must trigger
|
|
153
|
+
class And < Condition
|
|
154
|
+
attr_reader :conditions
|
|
155
|
+
|
|
156
|
+
def initialize(*conditions)
|
|
157
|
+
@conditions = conditions.flat_map { |c| c.is_a?(And) ? c.conditions : [c] }
|
|
158
|
+
super()
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def should_terminate(context)
|
|
162
|
+
reasons = []
|
|
163
|
+
@conditions.each do |c|
|
|
164
|
+
r = c.should_terminate(context)
|
|
165
|
+
return Result.new(should_terminate: false) unless r.should_terminate
|
|
166
|
+
|
|
167
|
+
reasons << r.reason unless r.reason.to_s.empty?
|
|
168
|
+
end
|
|
169
|
+
Result.new(should_terminate: true, reason: reasons.join(' AND '))
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Any child triggers
|
|
174
|
+
class Or < Condition
|
|
175
|
+
attr_reader :conditions
|
|
176
|
+
|
|
177
|
+
def initialize(*conditions)
|
|
178
|
+
@conditions = conditions.flat_map { |c| c.is_a?(Or) ? c.conditions : [c] }
|
|
179
|
+
super()
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def should_terminate(context)
|
|
183
|
+
@conditions.each do |c|
|
|
184
|
+
r = c.should_terminate(context)
|
|
185
|
+
return r if r.should_terminate
|
|
186
|
+
end
|
|
187
|
+
Result.new(should_terminate: false)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'errors'
|
|
5
|
+
|
|
6
|
+
module Conductor
|
|
7
|
+
module Agents
|
|
8
|
+
# Wire values for ToolConfig#toolType. Only +worker+ and +cli+ tools run in this
|
|
9
|
+
# process; every other type is executed by the Conductor server.
|
|
10
|
+
module ToolType
|
|
11
|
+
WORKER = 'worker'
|
|
12
|
+
HTTP = 'http'
|
|
13
|
+
API = 'api'
|
|
14
|
+
MCP = 'mcp'
|
|
15
|
+
HUMAN = 'human'
|
|
16
|
+
AGENT_TOOL = 'agent_tool'
|
|
17
|
+
GENERATE_IMAGE = 'generate_image'
|
|
18
|
+
GENERATE_AUDIO = 'generate_audio'
|
|
19
|
+
GENERATE_VIDEO = 'generate_video'
|
|
20
|
+
GENERATE_PDF = 'generate_pdf'
|
|
21
|
+
RAG_INDEX = 'rag_index'
|
|
22
|
+
RAG_SEARCH = 'rag_search'
|
|
23
|
+
PULL_WORKFLOW_MESSAGES = 'pull_workflow_messages'
|
|
24
|
+
CLI = 'cli'
|
|
25
|
+
|
|
26
|
+
MEDIA = [GENERATE_IMAGE, GENERATE_AUDIO, GENERATE_VIDEO, GENERATE_PDF].freeze
|
|
27
|
+
RAG = [RAG_INDEX, RAG_SEARCH].freeze
|
|
28
|
+
LOCAL = [WORKER, CLI].freeze
|
|
29
|
+
ALL = [WORKER, HTTP, API, MCP, HUMAN, AGENT_TOOL, *MEDIA, *RAG, PULL_WORKFLOW_MESSAGES, CLI].freeze
|
|
30
|
+
|
|
31
|
+
def self.valid?(type)
|
|
32
|
+
ALL.include?(type.to_s)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A tool call with pre-filled arguments (Agent#prefill_tools)
|
|
37
|
+
PrefillToolCall = Struct.new(:tool_name, :arguments, :tool, keyword_init: true) do
|
|
38
|
+
def to_h
|
|
39
|
+
{ 'toolName' => tool_name, 'arguments' => arguments || {} }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A tool an agent can call. This is the developer-facing type: the counterpart of
|
|
44
|
+
# the Java SDK's @Tool / HttpTool / McpTool builders and the Python SDK's @tool /
|
|
45
|
+
# http_tool / mcp_tool functions. The wire-level tool config the server receives is
|
|
46
|
+
# produced by ConfigSerializer and never handed to developers.
|
|
47
|
+
#
|
|
48
|
+
# Worker tools are created by the Tools DSL (+tool def ...+); server-side tools by
|
|
49
|
+
# the factories below (Tool.http, .mcp, .human, .agent, ...).
|
|
50
|
+
class Tool
|
|
51
|
+
RETRY_POLICIES = %w[fixed linear_backoff exponential_backoff].freeze
|
|
52
|
+
RETRY_LOGIC = {
|
|
53
|
+
'fixed' => 'FIXED',
|
|
54
|
+
'linear_backoff' => 'LINEAR_BACKOFF',
|
|
55
|
+
'exponential_backoff' => 'EXPONENTIAL_BACKOFF'
|
|
56
|
+
}.freeze
|
|
57
|
+
CREDENTIAL_PLACEHOLDER = /\$\{(\w+)\}/
|
|
58
|
+
|
|
59
|
+
attr_accessor :name, :description, :input_schema, :output_schema, :func,
|
|
60
|
+
:approval_required, :timeout_seconds, :tool_type, :config,
|
|
61
|
+
:guardrails, :credentials, :stateful, :max_calls,
|
|
62
|
+
:retry_count, :retry_delay_seconds, :retry_policy
|
|
63
|
+
|
|
64
|
+
# @param name [String] tool name; for worker tools this is also the Conductor task name
|
|
65
|
+
# @param func [Proc, Method, nil] local implementation; nil for server-side tools
|
|
66
|
+
def initialize(name:, description: '', input_schema: nil, output_schema: nil, func: nil,
|
|
67
|
+
approval_required: false, timeout_seconds: nil, tool_type: ToolType::WORKER,
|
|
68
|
+
config: nil, guardrails: nil, credentials: nil, stateful: false, max_calls: nil,
|
|
69
|
+
retry_count: 2, retry_delay_seconds: 2, retry_policy: 'linear_backoff')
|
|
70
|
+
raise ConfigurationError, 'tool name is required' if name.nil? || name.to_s.empty?
|
|
71
|
+
raise ConfigurationError, "unknown tool_type #{tool_type.inspect}" unless ToolType.valid?(tool_type)
|
|
72
|
+
unless RETRY_POLICIES.include?(retry_policy.to_s) || RETRY_LOGIC.value?(retry_policy.to_s)
|
|
73
|
+
raise ConfigurationError, "retry_policy must be one of #{RETRY_POLICIES.join(', ')}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@name = name.to_s
|
|
77
|
+
@description = description.to_s
|
|
78
|
+
@input_schema = input_schema || {}
|
|
79
|
+
@output_schema = output_schema || {}
|
|
80
|
+
@func = func
|
|
81
|
+
@approval_required = approval_required ? true : false
|
|
82
|
+
@timeout_seconds = timeout_seconds
|
|
83
|
+
@tool_type = tool_type.to_s
|
|
84
|
+
@config = config || {}
|
|
85
|
+
@guardrails = Array(guardrails)
|
|
86
|
+
@credentials = Array(credentials).map(&:to_s).uniq
|
|
87
|
+
@stateful = stateful ? true : false
|
|
88
|
+
@max_calls = max_calls
|
|
89
|
+
@retry_count = retry_count
|
|
90
|
+
@retry_delay_seconds = retry_delay_seconds
|
|
91
|
+
@retry_policy = retry_policy.to_s
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# True when this tool needs a worker polling in this process
|
|
95
|
+
def local?
|
|
96
|
+
!@func.nil? && ToolType::LOCAL.include?(@tool_type)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def server_side?
|
|
100
|
+
!local?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Conductor retryLogic value for this tool's retry policy
|
|
104
|
+
def retry_logic
|
|
105
|
+
RETRY_LOGIC.fetch(@retry_policy) { @retry_policy.upcase }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Add secret names this tool needs (deduplicated)
|
|
109
|
+
# @return [self]
|
|
110
|
+
def add_credentials(*names)
|
|
111
|
+
@credentials = (@credentials + names.flatten.map(&:to_s)).uniq
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A copy of this tool guarded by +guardrails+ (replaces any existing ones)
|
|
116
|
+
# @return [Tool]
|
|
117
|
+
def with_guardrails(*guardrails)
|
|
118
|
+
copy = dup
|
|
119
|
+
copy.guardrails = guardrails.flatten
|
|
120
|
+
copy
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Build a pre-filled call for Agent#prefill_tools
|
|
124
|
+
def call(**args)
|
|
125
|
+
PrefillToolCall.new(tool_name: @name, arguments: args.transform_keys(&:to_s), tool: self)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def to_s
|
|
129
|
+
"#<Conductor::Agents::Tool #{@name} (#{@tool_type})>"
|
|
130
|
+
end
|
|
131
|
+
alias inspect to_s
|
|
132
|
+
|
|
133
|
+
class << self
|
|
134
|
+
# Tool backed by an HTTP endpoint; the server makes the call.
|
|
135
|
+
# Headers may reference secrets as ${NAME}; every placeholder must be listed in +credentials+.
|
|
136
|
+
def http(name, url, description: '', method: 'GET', headers: nil, input_schema: nil,
|
|
137
|
+
accept: ['application/json'], content_type: 'application/json', credentials: nil)
|
|
138
|
+
creds = Array(credentials).map(&:to_s)
|
|
139
|
+
validate_placeholders!(headers, creds)
|
|
140
|
+
new(
|
|
141
|
+
name: name, description: description,
|
|
142
|
+
input_schema: input_schema || { 'type' => 'object', 'properties' => {} },
|
|
143
|
+
tool_type: ToolType::HTTP,
|
|
144
|
+
config: { 'url' => url, 'method' => method.to_s.upcase, 'headers' => headers || {},
|
|
145
|
+
'accept' => accept, 'contentType' => content_type },
|
|
146
|
+
credentials: creds
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Tools discovered from an OpenAPI endpoint; the server does the discovery.
|
|
151
|
+
def api(url, name: 'api_tools', description: nil, headers: nil, tool_names: nil, max_tools: 64, credentials: nil)
|
|
152
|
+
creds = Array(credentials).map(&:to_s)
|
|
153
|
+
validate_placeholders!(headers, creds)
|
|
154
|
+
config = { 'url' => url }
|
|
155
|
+
config['headers'] = headers if headers
|
|
156
|
+
config['tool_names'] = Array(tool_names) if tool_names
|
|
157
|
+
config['max_tools'] = max_tools
|
|
158
|
+
new(name: name, description: description || "API tools from #{url}", tool_type: ToolType::API,
|
|
159
|
+
config: config, credentials: creds)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Tools served by an MCP server; discovery (LIST_MCP_TOOLS) and calls happen on the server.
|
|
163
|
+
def mcp(server_url, name: 'mcp_tools', description: nil, headers: nil, tool_names: nil,
|
|
164
|
+
max_tools: 64, credentials: nil)
|
|
165
|
+
creds = Array(credentials).map(&:to_s)
|
|
166
|
+
validate_placeholders!(headers, creds)
|
|
167
|
+
config = { 'server_url' => server_url }
|
|
168
|
+
config['headers'] = headers if headers
|
|
169
|
+
config['tool_names'] = Array(tool_names) if tool_names
|
|
170
|
+
config['max_tools'] = max_tools
|
|
171
|
+
new(name: name, description: description || "MCP tools from #{server_url}", tool_type: ToolType::MCP,
|
|
172
|
+
config: config, credentials: creds)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Tool that pauses for a human answer (Conductor HUMAN task)
|
|
176
|
+
def human(name, description:, input_schema: nil)
|
|
177
|
+
new(
|
|
178
|
+
name: name, description: description, tool_type: ToolType::HUMAN,
|
|
179
|
+
input_schema: input_schema || {
|
|
180
|
+
'type' => 'object',
|
|
181
|
+
'properties' => { 'question' => { 'type' => 'string',
|
|
182
|
+
'description' => 'The question or request for the human operator.' } },
|
|
183
|
+
'required' => ['question']
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Another agent exposed as a tool (runs as a sub-workflow)
|
|
189
|
+
def agent(agent, name: nil, description: nil, retry_count: nil, retry_delay_seconds: nil, optional: nil)
|
|
190
|
+
agent_name = agent.respond_to?(:name) ? agent.name : agent.to_s
|
|
191
|
+
config = { 'agent' => agent }
|
|
192
|
+
config['retryCount'] = retry_count unless retry_count.nil?
|
|
193
|
+
config['retryDelaySeconds'] = retry_delay_seconds unless retry_delay_seconds.nil?
|
|
194
|
+
config['optional'] = optional unless optional.nil?
|
|
195
|
+
new(
|
|
196
|
+
name: name || agent_name,
|
|
197
|
+
description: description || "Invoke the #{agent_name} agent",
|
|
198
|
+
input_schema: {
|
|
199
|
+
'type' => 'object',
|
|
200
|
+
'properties' => { 'request' => { 'type' => 'string',
|
|
201
|
+
'description' => 'The request or question to send to this agent.' } },
|
|
202
|
+
'required' => ['request']
|
|
203
|
+
},
|
|
204
|
+
tool_type: ToolType::AGENT_TOOL,
|
|
205
|
+
config: config
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Media generation tools (server-side)
|
|
210
|
+
def image(name, description:, llm_provider:, model:, input_schema: nil, **defaults)
|
|
211
|
+
media(ToolType::GENERATE_IMAGE, 'GENERATE_IMAGE', name, description, llm_provider, model, input_schema, defaults)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def audio(name, description:, llm_provider:, model:, input_schema: nil, **defaults)
|
|
215
|
+
media(ToolType::GENERATE_AUDIO, 'GENERATE_AUDIO', name, description, llm_provider, model, input_schema, defaults)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def video(name, description:, llm_provider:, model:, input_schema: nil, **defaults)
|
|
219
|
+
media(ToolType::GENERATE_VIDEO, 'GENERATE_VIDEO', name, description, llm_provider, model, input_schema, defaults)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def pdf(name = 'generate_pdf', description: 'Generate a PDF document.', input_schema: nil, **defaults)
|
|
223
|
+
new(name: name, description: description, tool_type: ToolType::GENERATE_PDF,
|
|
224
|
+
input_schema: input_schema || { 'type' => 'object', 'properties' => {} },
|
|
225
|
+
config: { 'taskType' => 'GENERATE_PDF' }.merge(stringify(defaults)))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# RAG tools (server-side)
|
|
229
|
+
def index(name, description:, vector_db:, index:, embedding_model_provider:, embedding_model:,
|
|
230
|
+
namespace: 'default_ns', chunk_size: nil, chunk_overlap: nil, dimensions: nil, input_schema: nil)
|
|
231
|
+
config = { 'taskType' => 'LLM_INDEX_TEXT', 'vectorDB' => vector_db, 'namespace' => namespace, 'index' => index,
|
|
232
|
+
'embeddingModelProvider' => embedding_model_provider, 'embeddingModel' => embedding_model }
|
|
233
|
+
config['chunkSize'] = chunk_size if chunk_size
|
|
234
|
+
config['chunkOverlap'] = chunk_overlap if chunk_overlap
|
|
235
|
+
config['dimensions'] = dimensions if dimensions
|
|
236
|
+
new(name: name, description: description, tool_type: ToolType::RAG_INDEX,
|
|
237
|
+
input_schema: input_schema || { 'type' => 'object', 'properties' => {} }, config: config)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def search(name, description:, vector_db:, index:, embedding_model_provider:, embedding_model:,
|
|
241
|
+
namespace: 'default_ns', max_results: 5, dimensions: nil, input_schema: nil)
|
|
242
|
+
config = { 'taskType' => 'LLM_SEARCH_INDEX', 'vectorDB' => vector_db, 'namespace' => namespace, 'index' => index,
|
|
243
|
+
'embeddingModelProvider' => embedding_model_provider, 'embeddingModel' => embedding_model,
|
|
244
|
+
'maxResults' => max_results }
|
|
245
|
+
config['dimensions'] = dimensions if dimensions
|
|
246
|
+
new(name: name, description: description, tool_type: ToolType::RAG_SEARCH,
|
|
247
|
+
input_schema: input_schema || { 'type' => 'object', 'properties' => {} }, config: config)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Wait for messages posted to the execution (PULL_WORKFLOW_MESSAGES)
|
|
251
|
+
def wait_for_message(name, description:, batch_size: 1, blocking: true)
|
|
252
|
+
config = { 'batchSize' => batch_size }
|
|
253
|
+
config['blocking'] = false unless blocking
|
|
254
|
+
new(name: name, description: description, tool_type: ToolType::PULL_WORKFLOW_MESSAGES,
|
|
255
|
+
input_schema: { 'type' => 'object', 'properties' => {} }, config: config)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private
|
|
259
|
+
|
|
260
|
+
def media(tool_type, task_type, name, description, llm_provider, model, input_schema, defaults)
|
|
261
|
+
new(name: name, description: description, tool_type: tool_type,
|
|
262
|
+
input_schema: input_schema || { 'type' => 'object', 'properties' => {} },
|
|
263
|
+
config: { 'taskType' => task_type, 'llmProvider' => llm_provider, 'model' => model }.merge(stringify(defaults)))
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def stringify(hash)
|
|
267
|
+
hash.transform_keys(&:to_s)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def validate_placeholders!(headers, credentials)
|
|
271
|
+
return unless headers
|
|
272
|
+
|
|
273
|
+
placeholders = headers.to_s.scan(CREDENTIAL_PLACEHOLDER).flatten.uniq
|
|
274
|
+
missing = placeholders - credentials
|
|
275
|
+
return if missing.empty?
|
|
276
|
+
|
|
277
|
+
raise ConfigurationError,
|
|
278
|
+
"Header placeholder(s) #{missing.inspect} not declared in credentials: #{credentials.inspect}"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Conductor
|
|
4
|
+
module Agents
|
|
5
|
+
module Tools
|
|
6
|
+
# Builds a JSON schema for a tool method from its keyword arguments.
|
|
7
|
+
#
|
|
8
|
+
# Ruby exposes keyword names and whether they are required (+Method#parameters+) but
|
|
9
|
+
# never the default expressions, and the Tools DSL uses the default as the type:
|
|
10
|
+
#
|
|
11
|
+
# tool def get_weather(city: String, units: 'metric', limit: 10, tags: [String], mode: %w[a b])
|
|
12
|
+
#
|
|
13
|
+
# so the defaults are read from the method's AST (RubyVM::AbstractSyntaxTree.of). That
|
|
14
|
+
# works on MRI whenever the method's source file is on disk (and for eval'd code on
|
|
15
|
+
# Ruby >= 3.2 with RubyVM.keep_script_lines = true). When the AST is unavailable the
|
|
16
|
+
# builder falls back to Python's behaviour: every keyword becomes an untyped property
|
|
17
|
+
# ({}), required when the keyword has no default.
|
|
18
|
+
module SchemaBuilder # rubocop:disable Metrics/ModuleLength
|
|
19
|
+
CLASS_TYPES = {
|
|
20
|
+
'String' => 'string', 'Symbol' => 'string',
|
|
21
|
+
'Integer' => 'integer',
|
|
22
|
+
'Float' => 'number', 'Numeric' => 'number', 'BigDecimal' => 'number',
|
|
23
|
+
'TrueClass' => 'boolean', 'FalseClass' => 'boolean',
|
|
24
|
+
'Hash' => 'object', 'Array' => 'array',
|
|
25
|
+
'Time' => 'string', 'Date' => 'string', 'DateTime' => 'string'
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
POSITIONAL = %i[req opt rest].freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# @param method [Method, UnboundMethod]
|
|
33
|
+
# @return [Hash] JSON schema for the tool input
|
|
34
|
+
def input_schema(method)
|
|
35
|
+
params = method.parameters
|
|
36
|
+
positional = params.select { |kind, _| POSITIONAL.include?(kind) }
|
|
37
|
+
unless positional.empty?
|
|
38
|
+
raise ConfigurationError,
|
|
39
|
+
"tool #{method.name}: use keyword arguments only (found positional #{positional.map(&:last).inspect})"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
defaults = keyword_defaults(method)
|
|
43
|
+
properties = {}
|
|
44
|
+
required = []
|
|
45
|
+
|
|
46
|
+
params.each do |kind, name|
|
|
47
|
+
case kind
|
|
48
|
+
when :keyreq
|
|
49
|
+
properties[name.to_s] = defaults.key?(name) ? schema_for(defaults[name]) : {}
|
|
50
|
+
required << name.to_s
|
|
51
|
+
when :key
|
|
52
|
+
if defaults.key?(name)
|
|
53
|
+
schema, is_required = schema_for_default(defaults[name])
|
|
54
|
+
properties[name.to_s] = schema
|
|
55
|
+
required << name.to_s if is_required
|
|
56
|
+
else
|
|
57
|
+
properties[name.to_s] = {}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
schema = { 'type' => 'object', 'properties' => properties }
|
|
63
|
+
schema['required'] = required unless required.empty?
|
|
64
|
+
schema
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Default output schema for worker tools: Dispatch always returns a JSON object
|
|
68
|
+
def default_output_schema
|
|
69
|
+
{ 'type' => 'object', 'additionalProperties' => {} }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Map keyword name => default AST node, or {} when no AST is available
|
|
73
|
+
def keyword_defaults(method)
|
|
74
|
+
ast = ast_of(method)
|
|
75
|
+
return {} unless ast
|
|
76
|
+
|
|
77
|
+
args = find_node(ast, :ARGS)
|
|
78
|
+
return {} unless args
|
|
79
|
+
|
|
80
|
+
kw = args.children[7]
|
|
81
|
+
result = {}
|
|
82
|
+
each_node(kw) do |node|
|
|
83
|
+
next unless node.type == :KW_ARG
|
|
84
|
+
|
|
85
|
+
lasgn = node.children[0]
|
|
86
|
+
next unless lasgn.respond_to?(:type) && lasgn.type == :LASGN
|
|
87
|
+
|
|
88
|
+
name, default = lasgn.children
|
|
89
|
+
# required keywords (city:) carry a Symbol placeholder instead of a default node
|
|
90
|
+
result[name] = default if default.is_a?(RubyVM::AbstractSyntaxTree::Node)
|
|
91
|
+
end
|
|
92
|
+
result
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ast_of(method)
|
|
96
|
+
return nil unless defined?(RubyVM::AbstractSyntaxTree)
|
|
97
|
+
|
|
98
|
+
RubyVM::AbstractSyntaxTree.of(method)
|
|
99
|
+
rescue StandardError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Schema for a default that stands for a *type* (class constant or array of one)
|
|
104
|
+
def schema_for(node)
|
|
105
|
+
schema_for_default(node).first
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Array(Hash, Boolean)] schema and whether the parameter is required
|
|
109
|
+
def schema_for_default(node)
|
|
110
|
+
case node.type
|
|
111
|
+
when :CONST
|
|
112
|
+
type = CLASS_TYPES[node.children[0].to_s]
|
|
113
|
+
[type ? { 'type' => type } : {}, true]
|
|
114
|
+
when :COLON2
|
|
115
|
+
type = CLASS_TYPES[node.children[1].to_s]
|
|
116
|
+
[type ? { 'type' => type } : {}, true]
|
|
117
|
+
when :STR
|
|
118
|
+
[{ 'type' => 'string', 'default' => node.children[0] }, false]
|
|
119
|
+
when :DSTR, :XSTR, :DXSTR
|
|
120
|
+
[{ 'type' => 'string' }, false]
|
|
121
|
+
when :LIT, :INTEGER, :FLOAT, :RATIONAL, :IMAGINARY
|
|
122
|
+
literal_schema(node.children[0])
|
|
123
|
+
when :TRUE
|
|
124
|
+
[{ 'type' => 'boolean', 'default' => true }, false]
|
|
125
|
+
when :FALSE
|
|
126
|
+
[{ 'type' => 'boolean', 'default' => false }, false]
|
|
127
|
+
when :NIL
|
|
128
|
+
[{}, false]
|
|
129
|
+
when :LIST, :ZLIST
|
|
130
|
+
list_schema(node)
|
|
131
|
+
when :HASH
|
|
132
|
+
[{ 'type' => 'object', 'default' => {} }, false]
|
|
133
|
+
else
|
|
134
|
+
[{}, false]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def literal_schema(value)
|
|
139
|
+
case value
|
|
140
|
+
when Integer then [{ 'type' => 'integer', 'default' => value }, false]
|
|
141
|
+
when Float then [{ 'type' => 'number', 'default' => value }, false]
|
|
142
|
+
when Symbol then [{ 'type' => 'string', 'default' => value.to_s }, false]
|
|
143
|
+
when Regexp then [{ 'type' => 'string', 'pattern' => value.source }, false]
|
|
144
|
+
else [{}, false]
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def list_schema(node)
|
|
149
|
+
elements = node.type == :ZLIST ? [] : node.children.compact
|
|
150
|
+
return [{ 'type' => 'array', 'default' => [] }, false] if elements.empty?
|
|
151
|
+
|
|
152
|
+
if elements.size == 1 && %i[CONST COLON2].include?(elements[0].type)
|
|
153
|
+
item, = schema_for_default(elements[0])
|
|
154
|
+
return [{ 'type' => 'array', 'items' => item }, true]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if elements.all? { |e| e.type == :STR }
|
|
158
|
+
values = elements.map { |e| e.children[0] }
|
|
159
|
+
return [{ 'type' => 'string', 'enum' => values, 'default' => values.first }, false]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if elements.all? { |e| %i[LIT INTEGER].include?(e.type) && e.children[0].is_a?(Integer) }
|
|
163
|
+
values = elements.map { |e| e.children[0] }
|
|
164
|
+
return [{ 'type' => 'integer', 'enum' => values, 'default' => values.first }, false]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
[{ 'type' => 'array' }, false]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def find_node(node, type)
|
|
171
|
+
found = nil
|
|
172
|
+
each_node(node) do |n|
|
|
173
|
+
if n.type == type
|
|
174
|
+
found = n
|
|
175
|
+
break
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
found
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def each_node(node, &block)
|
|
182
|
+
return unless node.is_a?(RubyVM::AbstractSyntaxTree::Node)
|
|
183
|
+
|
|
184
|
+
block.call(node)
|
|
185
|
+
node.children.each { |child| each_node(child, &block) }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|