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,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'tool'
5
+ require_relative 'tools'
6
+ require_relative 'guardrail'
7
+ require_relative 'termination'
8
+ require_relative 'handoff'
9
+ require_relative 'callback_handler'
10
+ require_relative 'memory'
11
+ require_relative 'prompt_template'
12
+
13
+ module Conductor
14
+ module Agents
15
+ # Multi-agent orchestration strategies (wire values are lowercase snake_case)
16
+ module Strategy
17
+ HANDOFF = 'handoff'
18
+ SEQUENTIAL = 'sequential'
19
+ PARALLEL = 'parallel'
20
+ ROUTER = 'router'
21
+ ROUND_ROBIN = 'round_robin'
22
+ RANDOM = 'random'
23
+ SWARM = 'swarm'
24
+ MANUAL = 'manual'
25
+ PLAN_EXECUTE = 'plan_execute'
26
+ ALL = [HANDOFF, SEQUENTIAL, PARALLEL, ROUTER, ROUND_ROBIN, RANDOM, SWARM, MANUAL, PLAN_EXECUTE].freeze
27
+
28
+ def self.normalize(value)
29
+ s = value.to_s.downcase
30
+ raise ConfigurationError, "invalid strategy #{value.inspect}; use one of #{ALL.join(', ')}" unless ALL.include?(s)
31
+
32
+ s
33
+ end
34
+ end
35
+
36
+ # An agent definition. Nothing here talks to the server: ConfigSerializer turns the
37
+ # tree into agentConfig and AgentRuntime runs it (call_sync / call_async delegate to
38
+ # Conductor::Agents.runtime).
39
+ #
40
+ # agent = Agent.new(name: 'weather', model: 'openai/gpt-4o', instructions: 'Answer weather questions.')
41
+ # agent.add_tool :get_weather
42
+ # puts agent.call_sync('Weather in Lisbon?')
43
+ class Agent
44
+ NAME_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_-]*\z/
45
+
46
+ attr_reader :name, :tools, :agents, :guardrails, :handoffs, :callbacks, :credentials, :callback_procs
47
+ attr_accessor :model, :instructions, :router, :output_type, :memory, :termination,
48
+ :max_turns, :max_tokens, :timeout_seconds, :temperature, :stateful,
49
+ :metadata, :description, :external, :base_url, :prefill_tools, :approval_handler,
50
+ :planner, :fallback, :fallback_max_turns, :planner_context
51
+
52
+ # @param name [String] ^[a-zA-Z_][a-zA-Z0-9_-]*$
53
+ # @param model [String, nil] "provider/model"; the left side is the server integration name
54
+ # @param instructions [String, PromptTemplate, Proc]
55
+ # @param strategy [Symbol, String] how sub-agents are orchestrated (default :handoff)
56
+ def initialize(name:, model: nil, instructions: '', tools: [], agents: [], strategy: nil, router: nil,
57
+ output_type: nil, guardrails: [], memory: nil, termination: nil, handoffs: [], callbacks: [],
58
+ credentials: [], max_turns: 25, max_tokens: nil, timeout_seconds: 0, temperature: nil,
59
+ stateful: false, metadata: nil, description: nil, external: false, base_url: nil,
60
+ prefill_tools: [], planner: nil, fallback: nil, fallback_max_turns: nil, planner_context: [])
61
+ @name = name.to_s
62
+ raise ConfigurationError, "invalid agent name #{name.inspect}: must match #{NAME_PATTERN.source}" unless NAME_PATTERN.match?(@name)
63
+ raise ConfigurationError, 'max_turns must be >= 1' unless max_turns.is_a?(Integer) && max_turns >= 1
64
+
65
+ @model = model
66
+ @instructions = instructions
67
+ @tools = []
68
+ @agents = []
69
+ @strategy = strategy.nil? ? nil : Strategy.normalize(strategy)
70
+ @router = router
71
+ @output_type = output_type
72
+ @guardrails = Array(guardrails)
73
+ @memory = memory
74
+ @termination = termination
75
+ @handoffs = Array(handoffs)
76
+ @callbacks = Array(callbacks)
77
+ @callback_procs = Hash.new { |h, k| h[k] = [] }
78
+ @credentials = Array(credentials).map(&:to_s).uniq
79
+ @max_turns = max_turns
80
+ @max_tokens = max_tokens
81
+ @timeout_seconds = timeout_seconds
82
+ @temperature = temperature
83
+ @stateful = stateful ? true : false
84
+ @metadata = metadata
85
+ @description = description
86
+ @external = external ? true : false
87
+ @base_url = base_url
88
+ @prefill_tools = Array(prefill_tools)
89
+ @approval_handler = nil
90
+ @planner = planner
91
+ @fallback = fallback
92
+ @fallback_max_turns = fallback_max_turns
93
+ @planner_context = Array(planner_context)
94
+ [planner, fallback].compact.each do |child|
95
+ raise ConfigurationError, 'planner and fallback must be Agents' unless child.is_a?(Agent)
96
+ end
97
+ raise ConfigurationError, 'strategy: :plan_execute requires planner:' if @strategy == Strategy::PLAN_EXECUTE && planner.nil?
98
+ raise ConfigurationError, 'planner and fallback require strategy: :plan_execute' if (planner || fallback) && @strategy != Strategy::PLAN_EXECUTE
99
+ raise ConfigurationError, 'strategy: :plan_execute requires tools:' if @strategy == Strategy::PLAN_EXECUTE && Array(tools).empty?
100
+
101
+ Array(tools).each { |t| add_tool(t) }
102
+ Array(agents).each { |a| add_agent(a) }
103
+ raise ConfigurationError, 'strategy: :router requires router:' if @strategy == Strategy::ROUTER && @router.nil?
104
+ end
105
+
106
+ # ── Strategy ──────────────────────────────────────────────────────
107
+
108
+ # @return [String] effective strategy (default handoff)
109
+ def strategy
110
+ @strategy || Strategy::HANDOFF
111
+ end
112
+
113
+ def strategy=(value)
114
+ @strategy = value.nil? ? nil : Strategy.normalize(value)
115
+ end
116
+
117
+ # True when the user set a strategy explicitly
118
+ def strategy_set?
119
+ !@strategy.nil?
120
+ end
121
+
122
+ # ── Tools ─────────────────────────────────────────────────────────
123
+
124
+ # Give the agent a tool.
125
+ # @param tool [Symbol, String, Tool, Module, Class, Agent] a tool name defined with
126
+ # `tool def`, a Tool, a module that `extend Conductor::Agents::Tools`, or another
127
+ # Agent (wrapped as an agent tool)
128
+ # @param credentials [Array<String>, nil] secret names when the scanner cannot see them
129
+ # @return [self]
130
+ def add_tool(tool, credentials: nil)
131
+ resolve_tool_defs(tool).each do |tool_def|
132
+ td = credentials ? tool_def.dup.tap { |d| d.credentials = tool_def.credentials.dup } : tool_def
133
+ td.add_credentials(*credentials) if credentials
134
+ raise ConfigurationError, "duplicate tool name #{td.name.inspect} on agent #{@name}" if @tools.any? { |t| t.name == td.name }
135
+
136
+ @tools << td
137
+ end
138
+ self
139
+ end
140
+
141
+ def add_tools(*tools)
142
+ tools.flatten.each { |t| add_tool(t) }
143
+ self
144
+ end
145
+
146
+ # @return [Tool, nil]
147
+ def tool(name)
148
+ @tools.find { |t| t.name == name.to_s }
149
+ end
150
+
151
+ # ── Team ──────────────────────────────────────────────────────────
152
+
153
+ # Add a member agent (same as agents: in the constructor)
154
+ def add_agent(agent)
155
+ raise ConfigurationError, "add_agent expects an Agent, got #{agent.class}" unless agent.is_a?(Agent)
156
+ raise ConfigurationError, "duplicate sub-agent name #{agent.name.inspect} under #{@name}" if @agents.any? { |a| a.name == agent.name }
157
+
158
+ @agents << agent
159
+ self
160
+ end
161
+
162
+ def add_agents(*agents)
163
+ agents.flatten.each { |a| add_agent(a) }
164
+ self
165
+ end
166
+
167
+ # Hand off to +agent+ when this agent's output mentions +on+ (String), or when the
168
+ # block/proc given as +on+ returns true.
169
+ def hands_off_to(agent, on:)
170
+ handoff = if on.respond_to?(:call)
171
+ Handoff::OnCondition.new(target: agent, condition: on)
172
+ else
173
+ Handoff::OnTextMention.new(target: agent, text: on)
174
+ end
175
+ @handoffs << handoff
176
+ self
177
+ end
178
+
179
+ def add_handoff(handoff)
180
+ @handoffs << handoff
181
+ self
182
+ end
183
+
184
+ # Sequential pipeline: a >> b >> c
185
+ def >>(other)
186
+ raise ConfigurationError, ">> expects an Agent, got #{other.class}" unless other.is_a?(Agent)
187
+
188
+ left = sequential_pipeline? ? @agents : [self]
189
+ right = other.sequential_pipeline? ? other.agents : [other]
190
+ members = left + right
191
+ Agent.new(name: members.map(&:name).join('_'), model: @model || other.model,
192
+ agents: members, strategy: Strategy::SEQUENTIAL)
193
+ end
194
+
195
+ def sequential_pipeline?
196
+ @strategy == Strategy::SEQUENTIAL && !@agents.empty?
197
+ end
198
+
199
+ # ── Guardrails / termination sugar ────────────────────────────────
200
+
201
+ # Scrub these words from the output before anyone sees it
202
+ def redact(words, name: "#{@name}_redact")
203
+ patterns = Array(words).map { |w| w.is_a?(Regexp) ? w : Regexp.escape(w.to_s) }
204
+ @guardrails << RegexGuardrail.new(patterns, mode: :block, position: :output, on_fail: :fix, name: name)
205
+ self
206
+ end
207
+
208
+ def add_guardrail(guardrail)
209
+ @guardrails << guardrail
210
+ self
211
+ end
212
+
213
+ # Stop when the output contains +text+
214
+ def stop_when(text, case_sensitive: false)
215
+ add_termination(Termination::TextMention.new(text, case_sensitive: case_sensitive))
216
+ end
217
+
218
+ # Stop after +messages+ messages
219
+ def stop_after(messages:)
220
+ add_termination(Termination::MaxMessage.new(messages))
221
+ end
222
+
223
+ def add_termination(condition)
224
+ @termination = @termination ? (@termination | condition) : condition
225
+ self
226
+ end
227
+
228
+ # ── Callbacks ─────────────────────────────────────────────────────
229
+
230
+ def add_callback(handler)
231
+ @callbacks << handler
232
+ self
233
+ end
234
+
235
+ # Register a block for a callback position (before_model, after_model, ...)
236
+ def callback(position, &block)
237
+ pos = position.to_s
238
+ raise ConfigurationError, "unknown callback position #{position.inspect}" unless CallbackHandler::POSITIONS.include?(pos)
239
+
240
+ @callback_procs[pos] << block
241
+ self
242
+ end
243
+
244
+ # Callables for +position+, or nil when nothing is registered
245
+ def callback_chain(position, logger: nil)
246
+ CallbackHandler.chain(position, @callbacks, @callback_procs[position.to_s], logger: logger)
247
+ end
248
+
249
+ # Positions with at least one handler or proc
250
+ def callback_positions
251
+ CallbackHandler::POSITIONS.reject { |p| callback_chain(p).nil? }
252
+ end
253
+
254
+ # ── Approval ──────────────────────────────────────────────────────
255
+
256
+ # Decide approval-required tool calls: the block receives an ApprovalRequest
257
+ def on_approval(&block)
258
+ @approval_handler = block
259
+ self
260
+ end
261
+
262
+ # ── Credentials ───────────────────────────────────────────────────
263
+
264
+ def add_credentials(*names)
265
+ @credentials = (@credentials + names.flatten.map(&:to_s)).uniq
266
+ self
267
+ end
268
+
269
+ # ── Execution (delegates to the default runtime) ──────────────────
270
+
271
+ # Run and block until the answer is ready
272
+ # @return [String]
273
+ def call_sync(prompt, session_id: nil, **options)
274
+ Conductor::Agents.runtime.call_sync(self, prompt, session_id: session_id, **options)
275
+ end
276
+
277
+ # Run in the background; returns an Execution. The block (if given) receives the answer.
278
+ def call_async(prompt, session_id: nil, **options, &on_done)
279
+ Conductor::Agents.runtime.call_async(self, prompt, session_id: session_id, **options, &on_done)
280
+ end
281
+
282
+ # ── Introspection ─────────────────────────────────────────────────
283
+
284
+ # Every agent in the tree (self first), including router, planner-style children and agent tools
285
+ def all_agents
286
+ list = [self]
287
+ @agents.each { |a| list.concat(a.all_agents) }
288
+ [@router, @planner, @fallback].each { |a| list.concat(a.all_agents) if a.is_a?(Agent) }
289
+ @tools.each do |t|
290
+ child = t.config['agent'] if t.tool_type == ToolType::AGENT_TOOL
291
+ list.concat(child.all_agents) if child.is_a?(Agent)
292
+ end
293
+ list.uniq
294
+ end
295
+
296
+ # True when this agent or anything under it is stateful
297
+ def stateful_tree?
298
+ all_agents.any? { |a| a.stateful || a.tools.any?(&:stateful) }
299
+ end
300
+
301
+ def to_s
302
+ "#<Conductor::Agents::Agent #{@name} model=#{@model.inspect} tools=#{@tools.size} agents=#{@agents.size}>"
303
+ end
304
+ alias inspect to_s
305
+
306
+ private
307
+
308
+ def resolve_tool_defs(tool)
309
+ case tool
310
+ when Tool then [tool]
311
+ when Symbol, String
312
+ [Tools.lookup(tool) || raise(ConfigurationError, "no tool named #{tool.inspect}; define it with `tool def #{tool}(...)` first")]
313
+ when Agent then [Tool.agent(tool)]
314
+ when Module
315
+ raise ConfigurationError, "#{tool} has no tools; use `extend Conductor::Agents::Tools` and `tool def ...`" unless tool.respond_to?(:tool_defs)
316
+
317
+ tool.tool_defs
318
+ else
319
+ raise ConfigurationError, "cannot use #{tool.inspect} as a tool"
320
+ end
321
+ end
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Conductor
4
+ module Agents
5
+ # Lifecycle hooks. Subclass and override any method; each runs as a worker task
6
+ # named <agent>_<position> that the server schedules at that point.
7
+ #
8
+ # class Timing < Conductor::Agents::CallbackHandler
9
+ # def on_model_start(messages: nil, **) = (@t0 = Time.now; nil)
10
+ # def on_model_end(llm_result: nil, **) = (puts Time.now - @t0; nil)
11
+ # end
12
+ #
13
+ # Return nil to continue to the next handler, or a non-empty Hash to short-circuit and
14
+ # hand that Hash to the server as an override.
15
+ class CallbackHandler
16
+ POSITION_TO_METHOD = {
17
+ 'before_agent' => :on_agent_start,
18
+ 'after_agent' => :on_agent_end,
19
+ 'before_model' => :on_model_start,
20
+ 'after_model' => :on_model_end,
21
+ 'before_tool' => :on_tool_start,
22
+ 'after_tool' => :on_tool_end
23
+ }.freeze
24
+
25
+ POSITIONS = POSITION_TO_METHOD.keys.freeze
26
+
27
+ def on_agent_start(**_kwargs); end
28
+ def on_agent_end(**_kwargs); end
29
+ def on_model_start(**_kwargs); end
30
+ def on_model_end(**_kwargs); end
31
+ def on_tool_start(**_kwargs); end
32
+ def on_tool_end(**_kwargs); end
33
+
34
+ # True when this handler overrides the hook for +position+
35
+ def handles?(position)
36
+ method_name = POSITION_TO_METHOD.fetch(position.to_s)
37
+ self.class.instance_method(method_name).owner != CallbackHandler
38
+ end
39
+
40
+ class << self
41
+ # Build one callable for +position+ from a list of handlers (and optional procs),
42
+ # or nil when nothing is registered. First non-empty Hash wins; errors are logged.
43
+ # @return [Proc, nil]
44
+ def chain(position, handlers, procs = [], logger: nil)
45
+ position = position.to_s
46
+ method_name = POSITION_TO_METHOD.fetch(position)
47
+ active = Array(handlers).select { |h| h.handles?(position) }
48
+ callables = Array(procs) + active.map { |h| h.method(method_name) }
49
+ return nil if callables.empty?
50
+
51
+ lambda do |**kwargs|
52
+ callables.each do |callable|
53
+ result = callable.call(**kwargs)
54
+ return result if result.is_a?(Hash) && !result.empty?
55
+ rescue StandardError => e
56
+ logger&.error("callback #{position} failed: #{e.class}: #{e.message}")
57
+ end
58
+ {}
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'agent'
4
+
5
+ module Conductor
6
+ module Agents
7
+ # Serializes an Agent tree into the agentConfig JSON the server compiles. Same shape
8
+ # as the Python SDK's AgentConfigSerializer: camelCase keys, nils dropped, strategy only
9
+ # on agents with sub-agents, agent credentials at the top level and tool credentials
10
+ # under config.credentials.
11
+ #
12
+ # Two Ruby-specific rules:
13
+ # - a team parent with no model inherits the first member's model (the server requires
14
+ # a model on every agent config);
15
+ # - members that declared hands_off_to make a team with no explicit strategy a swarm,
16
+ # and their handoffs are hoisted to the team, which is where the server reads them.
17
+ class ConfigSerializer
18
+ def self.serialize(agent)
19
+ new.serialize(agent)
20
+ end
21
+
22
+ # @param agent [Agent]
23
+ # @return [Hash] agentConfig
24
+ def serialize(agent)
25
+ serialize_agent(agent)
26
+ end
27
+
28
+ private
29
+
30
+ def serialize_agent(agent)
31
+ has_sub_agents = !agent.agents.empty?
32
+ strategy, handoffs = effective_strategy_and_handoffs(agent)
33
+
34
+ config = {
35
+ 'name' => agent.name,
36
+ 'model' => effective_model(agent),
37
+ 'baseUrl' => agent.base_url,
38
+ 'strategy' => has_sub_agents || agent.planner || agent.fallback ? strategy : nil,
39
+ 'maxTurns' => agent.max_turns,
40
+ 'timeoutSeconds' => agent.timeout_seconds,
41
+ 'external' => agent.external,
42
+ 'description' => agent.description,
43
+ 'instructions' => serialize_instructions(agent.instructions)
44
+ }
45
+ config['tools'] = agent.tools.map { |t| serialize_tool(t, agent_stateful: agent.stateful) } unless agent.tools.empty?
46
+ config['agents'] = agent.agents.map { |a| serialize_agent(a) } if has_sub_agents
47
+ config.merge!(serialize_plan(agent))
48
+ config['router'] = serialize_router(agent) unless agent.router.nil?
49
+ config['outputType'] = serialize_output_type(agent.output_type) unless agent.output_type.nil?
50
+ config['guardrails'] = agent.guardrails.map { |g| serialize_guardrail(g) } unless agent.guardrails.empty?
51
+ config['memory'] = serialize_memory(agent.memory) if agent.memory && !agent.memory.empty?
52
+ config.merge!(serialize_scalars(agent))
53
+ config['termination'] = serialize_termination(agent.termination) unless agent.termination.nil?
54
+ config['handoffs'] = handoffs.map { |h| serialize_handoff(h, agent.name) } unless handoffs.empty?
55
+ config.merge!(serialize_extras(agent))
56
+ config.compact
57
+ end
58
+
59
+ def serialize_scalars(agent)
60
+ {
61
+ 'maxTokens' => agent.max_tokens,
62
+ 'temperature' => agent.temperature
63
+ }
64
+ end
65
+
66
+ def serialize_plan(agent)
67
+ config = {}
68
+ config['planner'] = serialize_agent(agent.planner) if agent.planner
69
+ config['fallback'] = serialize_agent(agent.fallback) if agent.fallback
70
+ config['fallbackMaxTurns'] = agent.fallback_max_turns unless agent.fallback_max_turns.nil?
71
+ config['plannerContext'] = agent.planner_context unless agent.planner_context.empty?
72
+ config
73
+ end
74
+
75
+ def serialize_extras(agent)
76
+ extras = {}
77
+ extras['metadata'] = agent.metadata if agent.metadata && !agent.metadata.empty?
78
+ callbacks = agent.callback_positions.map { |p| { 'position' => p, 'taskName' => "#{agent.name}_#{p}" } }
79
+ extras['callbacks'] = callbacks unless callbacks.empty?
80
+ extras['prefillTools'] = agent.prefill_tools.map(&:to_h) unless agent.prefill_tools.empty?
81
+ extras['credentials'] = agent.credentials unless agent.credentials.empty?
82
+ extras
83
+ end
84
+
85
+ def effective_model(agent)
86
+ return agent.model if agent.model && !agent.model.to_s.empty?
87
+ return nil if agent.external
88
+
89
+ inherited = (agent.agents + [agent.planner, agent.fallback].compact).map { |a| effective_model(a) }.compact.first
90
+ return inherited if inherited
91
+
92
+ raise ConfigurationError,
93
+ "agent #{agent.name.inspect} has no model: pass model: 'provider/model' (the server requires one)"
94
+ end
95
+
96
+ # Swarm hoisting: members with hands_off_to make the parent a swarm unless the user chose a strategy
97
+ def effective_strategy_and_handoffs(agent)
98
+ member_handoffs = agent.agents.flat_map(&:handoffs)
99
+ return [agent.strategy, agent.handoffs] if member_handoffs.empty?
100
+
101
+ strategy = agent.strategy_set? ? agent.strategy : Strategy::SWARM
102
+ return [strategy, agent.handoffs] unless strategy == Strategy::SWARM
103
+
104
+ hoisted = (agent.handoffs + member_handoffs).uniq { |h| [h.class, h.target, h.respond_to?(:text) ? h.text : nil] }
105
+ [strategy, hoisted]
106
+ end
107
+
108
+ def serialize_instructions(instructions)
109
+ case instructions
110
+ when PromptTemplate then instructions.to_h
111
+ when Proc, Method then instructions.call
112
+ when nil then nil
113
+ else
114
+ s = instructions.to_s
115
+ s.empty? ? nil : s
116
+ end
117
+ end
118
+
119
+ def serialize_tool(tool_def, agent_stateful: false)
120
+ result = {
121
+ 'name' => tool_def.name,
122
+ 'description' => tool_def.description,
123
+ 'inputSchema' => tool_def.input_schema,
124
+ 'toolType' => tool_def.tool_type
125
+ }
126
+ result['outputSchema'] = tool_def.output_schema unless tool_def.output_schema.nil? || tool_def.output_schema.empty?
127
+ result['approvalRequired'] = true if tool_def.approval_required
128
+ result['stateful'] = true if agent_stateful || tool_def.stateful
129
+ result['timeoutSeconds'] = tool_def.timeout_seconds unless tool_def.timeout_seconds.nil?
130
+ result['maxCalls'] = tool_def.max_calls unless tool_def.max_calls.nil?
131
+
132
+ unless tool_def.config.empty?
133
+ config = tool_def.config.transform_keys(&:to_s)
134
+ config['agentConfig'] = serialize_agent(config.delete('agent')) if tool_def.tool_type == ToolType::AGENT_TOOL && config.key?('agent')
135
+ result['config'] = config
136
+ end
137
+
138
+ result['guardrails'] = tool_def.guardrails.map { |g| serialize_guardrail(g) } unless tool_def.guardrails.empty?
139
+
140
+ unless tool_def.credentials.empty?
141
+ result['config'] ||= {}
142
+ result['config']['credentials'] = tool_def.credentials
143
+ end
144
+
145
+ result
146
+ end
147
+
148
+ def serialize_guardrail(guardrail)
149
+ result = {
150
+ 'name' => guardrail.name,
151
+ 'position' => guardrail.position,
152
+ 'onFail' => guardrail.on_fail,
153
+ 'maxRetries' => guardrail.max_retries,
154
+ 'guardrailType' => guardrail.guardrail_type
155
+ }
156
+ case guardrail
157
+ when RegexGuardrail
158
+ result['patterns'] = guardrail.pattern_strings
159
+ result['mode'] = guardrail.mode
160
+ result['message'] = guardrail.message if guardrail.message
161
+ when LlmGuardrail
162
+ result['model'] = guardrail.model
163
+ result['policy'] = guardrail.policy
164
+ result['maxTokens'] = guardrail.max_tokens if guardrail.max_tokens
165
+ else
166
+ result['taskName'] = guardrail.name
167
+ end
168
+ result
169
+ end
170
+
171
+ def serialize_termination(condition)
172
+ case condition
173
+ when Termination::TextMention
174
+ { 'type' => 'text_mention', 'text' => condition.text, 'caseSensitive' => condition.case_sensitive }
175
+ when Termination::StopMessage
176
+ { 'type' => 'stop_message', 'stopMessage' => condition.stop_message }
177
+ when Termination::MaxMessage
178
+ { 'type' => 'max_message', 'maxMessages' => condition.max_messages }
179
+ when Termination::TokenUsage
180
+ h = { 'type' => 'token_usage' }
181
+ h['maxTotalTokens'] = condition.max_total_tokens unless condition.max_total_tokens.nil?
182
+ h['maxPromptTokens'] = condition.max_prompt_tokens unless condition.max_prompt_tokens.nil?
183
+ h['maxCompletionTokens'] = condition.max_completion_tokens unless condition.max_completion_tokens.nil?
184
+ h
185
+ when Termination::And
186
+ { 'type' => 'and', 'conditions' => condition.conditions.map { |c| serialize_termination(c) } }
187
+ when Termination::Or
188
+ { 'type' => 'or', 'conditions' => condition.conditions.map { |c| serialize_termination(c) } }
189
+ else
190
+ { 'type' => 'unknown' }
191
+ end
192
+ end
193
+
194
+ def serialize_handoff(handoff, agent_name)
195
+ result = { 'target' => handoff.target }
196
+ case handoff
197
+ when Handoff::OnToolResult
198
+ result['type'] = 'on_tool_result'
199
+ result['toolName'] = handoff.tool_name
200
+ result['resultContains'] = handoff.result_contains if handoff.result_contains
201
+ when Handoff::OnTextMention
202
+ result['type'] = 'on_text_mention'
203
+ result['text'] = handoff.text
204
+ when Handoff::OnCondition
205
+ result['type'] = 'on_condition'
206
+ result['taskName'] = "#{agent_name}_handoff_#{handoff.target}"
207
+ else
208
+ result['type'] = 'unknown'
209
+ end
210
+ result
211
+ end
212
+
213
+ def serialize_router(agent)
214
+ router = agent.router
215
+ return serialize_agent(router) if router.is_a?(Agent)
216
+ return { 'taskName' => "#{agent.name}_router_fn" } if router.respond_to?(:call)
217
+
218
+ nil
219
+ end
220
+
221
+ # output_type: a JSON schema Hash, optionally wrapped as { schema:, class_name: }
222
+ def serialize_output_type(output_type)
223
+ schema = output_type.respond_to?(:to_json_schema) ? output_type.to_json_schema : output_type
224
+ schema = schema.transform_keys(&:to_s) if schema.is_a?(Hash)
225
+ if schema.is_a?(Hash) && (schema.key?('schema') || schema.key?('className') || schema.key?('class_name'))
226
+ result = {}
227
+ result['schema'] = schema['schema'] if schema['schema']
228
+ class_name = schema['className'] || schema['class_name']
229
+ result['className'] = class_name if class_name
230
+ return result
231
+ end
232
+
233
+ result = { 'schema' => schema }
234
+ result['className'] = schema['title'] if schema.is_a?(Hash) && schema['title']
235
+ result
236
+ end
237
+
238
+ def serialize_memory(memory)
239
+ result = {}
240
+ result['messages'] = memory.messages unless memory.messages.empty?
241
+ result['maxMessages'] = memory.max_messages if memory.max_messages
242
+ result
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../exceptions'
4
+
5
+ module Conductor
6
+ module Agents
7
+ # Base class for agent definition and runtime errors
8
+ class Error < ConductorError; end
9
+
10
+ # Invalid agent/tool definition (bad name, missing model, positional tool args, ...)
11
+ class ConfigurationError < Error; end
12
+
13
+ # secret('X') was called but X is neither on the task's runtimeMetadata nor in ENV
14
+ class CredentialNotFoundError < Error; end
15
+
16
+ # A tool returned something that cannot be serialized to JSON
17
+ class ToolSerializationError < Error; end
18
+
19
+ # The SSE stream could not be opened (non-200, connection failure, heartbeat-only)
20
+ class SseUnavailableError < Error; end
21
+
22
+ # Server-side agent API errors are the transport-level classes
23
+ AgentApiError = Conductor::AgentApiError
24
+ AgentNotFoundError = Conductor::AgentNotFoundError
25
+ end
26
+ end