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