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