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,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Conductor
|
|
4
|
+
module Agents
|
|
5
|
+
module Tools
|
|
6
|
+
# Finds the secret names a tool body reads, so they can be declared on the wire
|
|
7
|
+
# (TaskDef#runtime_metadata / tool.config.credentials) without a separate list.
|
|
8
|
+
#
|
|
9
|
+
# Only string literals are picked up:
|
|
10
|
+
#
|
|
11
|
+
# secret('GH_TOKEN') # => GH_TOKEN
|
|
12
|
+
# secrets_env('A', 'B') # => A, B
|
|
13
|
+
# secret(name) # dynamic: declare with add_tool ..., credentials: [...]
|
|
14
|
+
module SecretScanner
|
|
15
|
+
SECRET_METHODS = %i[secret secrets_env].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# @param method [Method, UnboundMethod]
|
|
20
|
+
# @return [Array<String>] literal secret names, in order of appearance
|
|
21
|
+
def scan(method)
|
|
22
|
+
ast = SchemaBuilder.ast_of(method)
|
|
23
|
+
return [] unless ast
|
|
24
|
+
|
|
25
|
+
names = []
|
|
26
|
+
SchemaBuilder.each_node(ast) do |node|
|
|
27
|
+
method_id, args = call_parts(node)
|
|
28
|
+
next unless SECRET_METHODS.include?(method_id)
|
|
29
|
+
|
|
30
|
+
SchemaBuilder.each_node(args) { |a| names << a.children[0] if a.type == :STR }
|
|
31
|
+
end
|
|
32
|
+
names.uniq
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call_parts(node)
|
|
36
|
+
case node.type
|
|
37
|
+
when :FCALL then [node.children[0], node.children[1]]
|
|
38
|
+
when :CALL, :QCALL then [node.children[1], node.children[2]]
|
|
39
|
+
else [nil, nil]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'runtime/secrets'
|
|
5
|
+
require_relative 'tool'
|
|
6
|
+
require_relative 'tools/schema_builder'
|
|
7
|
+
require_relative 'tools/secret_scanner'
|
|
8
|
+
|
|
9
|
+
module Conductor
|
|
10
|
+
module Agents
|
|
11
|
+
# The tool DSL.
|
|
12
|
+
#
|
|
13
|
+
# include Conductor::Agents # top level, or
|
|
14
|
+
# module Weather; extend Conductor::Agents::Tools; ... end
|
|
15
|
+
#
|
|
16
|
+
# tool def get_weather(city: String, units: 'metric')
|
|
17
|
+
# { temp_c: 21.0 }
|
|
18
|
+
# end
|
|
19
|
+
# describe :get_weather, 'Get the current weather for a city.'
|
|
20
|
+
# requires_approval :get_weather
|
|
21
|
+
#
|
|
22
|
+
# +tool+ receives the Symbol that +def+ returns, builds a Tool from the method
|
|
23
|
+
# (schema from keyword defaults, secrets from literal secret() calls) and registers it
|
|
24
|
+
# both on the receiver (Weather[:get_weather], Weather.tool_defs) and in the global
|
|
25
|
+
# registry that Agent#add_tool(:get_weather) consults.
|
|
26
|
+
module Tools
|
|
27
|
+
TOOL_OPTIONS = %i[name description input_schema output_schema approval_required timeout_seconds credentials
|
|
28
|
+
guardrails stateful max_calls retry_count retry_delay_seconds retry_policy external].freeze
|
|
29
|
+
|
|
30
|
+
# Weather[:current] on a module that `extend Conductor::Agents::Tools`
|
|
31
|
+
module Lookup
|
|
32
|
+
# @return [Tool]
|
|
33
|
+
def [](name)
|
|
34
|
+
fetch_tool(name)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# A module that extends Tools also gets [] and the secret helpers
|
|
40
|
+
def extended(base)
|
|
41
|
+
base.extend(Lookup)
|
|
42
|
+
base.extend(Secrets)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Global name => Tool registry shared by every scope that defines tools
|
|
46
|
+
def registry
|
|
47
|
+
@registry ||= {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def registry_mutex
|
|
51
|
+
@registry_mutex ||= Mutex.new
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def register(tool_def)
|
|
55
|
+
registry_mutex.synchronize { registry[tool_def.name] = tool_def }
|
|
56
|
+
tool_def
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Tool, nil]
|
|
60
|
+
def lookup(name)
|
|
61
|
+
registry_mutex.synchronize { registry[name.to_s] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Forget every registered tool (tests)
|
|
65
|
+
def clear!
|
|
66
|
+
registry_mutex.synchronize { registry.clear }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Build a Tool from a bound Method
|
|
70
|
+
# @param method [Method]
|
|
71
|
+
# @param name [String, nil] tool name override
|
|
72
|
+
def build(method, name: nil, **options)
|
|
73
|
+
unknown = options.keys - TOOL_OPTIONS
|
|
74
|
+
raise ConfigurationError, "unknown tool option(s): #{unknown.inspect}" unless unknown.empty?
|
|
75
|
+
|
|
76
|
+
tool_name = (name || method.name).to_s
|
|
77
|
+
input_schema = options.fetch(:input_schema) { SchemaBuilder.input_schema(method) }
|
|
78
|
+
credentials = SecretScanner.scan(method)
|
|
79
|
+
|
|
80
|
+
Tool.new(
|
|
81
|
+
name: tool_name,
|
|
82
|
+
description: options.fetch(:description) { humanize(method.name) },
|
|
83
|
+
input_schema: input_schema,
|
|
84
|
+
output_schema: options.fetch(:output_schema) { SchemaBuilder.default_output_schema },
|
|
85
|
+
func: options[:external] ? nil : method,
|
|
86
|
+
approval_required: options.fetch(:approval_required, false),
|
|
87
|
+
timeout_seconds: options[:timeout_seconds],
|
|
88
|
+
credentials: credentials + Array(options[:credentials]),
|
|
89
|
+
guardrails: Array(options[:guardrails]),
|
|
90
|
+
stateful: options.fetch(:stateful, false),
|
|
91
|
+
max_calls: options[:max_calls],
|
|
92
|
+
retry_count: options.fetch(:retry_count, 2),
|
|
93
|
+
retry_delay_seconds: options.fetch(:retry_delay_seconds, 2),
|
|
94
|
+
retry_policy: options.fetch(:retry_policy, 'linear_backoff')
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# "get_weather" => "Get weather"
|
|
99
|
+
def humanize(name)
|
|
100
|
+
words = name.to_s.tr('_', ' ').strip
|
|
101
|
+
return '' if words.empty?
|
|
102
|
+
|
|
103
|
+
words[0].upcase + words[1..]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Mark a method as a tool
|
|
108
|
+
# @param name [Symbol, String, Method] method name (what +def+ returns) or a Method
|
|
109
|
+
# @param options [Hash] Tool overrides: name: (tool name when it differs from the method name),
|
|
110
|
+
# description:, output_schema:, approval_required:, timeout_seconds:, credentials:, guardrails:,
|
|
111
|
+
# stateful:, max_calls:, retry_count:, retry_delay_seconds:, retry_policy:, external:
|
|
112
|
+
# @return [Tool]
|
|
113
|
+
def tool(name, **options)
|
|
114
|
+
method = name.is_a?(Method) ? name : resolve_tool_method(name.to_sym)
|
|
115
|
+
tool_name = options.delete(:name) || (name.is_a?(Method) ? name.name : name)
|
|
116
|
+
tool_def = Tools.build(method, name: tool_name, **options)
|
|
117
|
+
tool_registry[tool_def.name] = tool_def
|
|
118
|
+
Tools.register(tool_def)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Override the description the LLM sees
|
|
122
|
+
def describe(name, text)
|
|
123
|
+
fetch_tool(name).description = text.to_s
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Require a human approval before the tool runs
|
|
127
|
+
def requires_approval(name, enabled: true)
|
|
128
|
+
fetch_tool(name).approval_required = enabled
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Declare secret names the scanner could not see (dynamic names)
|
|
132
|
+
def tool_credentials(name, *secret_names)
|
|
133
|
+
fetch_tool(name).add_credentials(*secret_names)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Every tool defined in this scope, in definition order
|
|
137
|
+
# @return [Array<Tool>]
|
|
138
|
+
def tool_defs
|
|
139
|
+
tool_registry.values
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def tool_registry
|
|
145
|
+
@conductor_tool_registry ||= {} # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def fetch_tool(name)
|
|
149
|
+
tool_registry[name.to_s] || Tools.lookup(name) ||
|
|
150
|
+
raise(ConfigurationError, "no tool named #{name.inspect}; define it with `tool def #{name}(...)` first")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Find the method behind +tool def name+ for the current receiver:
|
|
154
|
+
# - top level / objects: the method is on self
|
|
155
|
+
# - module with `extend Tools`: `def` made an instance method; module_function it
|
|
156
|
+
# - class bodies (e.g. inside RSpec.describe): bind the instance method to a bare instance
|
|
157
|
+
def resolve_tool_method(name)
|
|
158
|
+
return method(name) if respond_to?(name, true)
|
|
159
|
+
|
|
160
|
+
if is_a?(Module) && (method_defined?(name) || private_method_defined?(name))
|
|
161
|
+
if instance_of?(Module)
|
|
162
|
+
module_function(name)
|
|
163
|
+
return method(name)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
return instance_method(name).bind(allocate)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
raise ConfigurationError, "tool #{name.inspect}: no such method on #{inspect}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Conductor::Agents - define agents in Ruby, run them on a Conductor server.
|
|
4
|
+
#
|
|
5
|
+
# require 'conductor/agents'
|
|
6
|
+
# include Conductor::Agents
|
|
7
|
+
#
|
|
8
|
+
# tool def get_weather(city: String, units: 'metric')
|
|
9
|
+
# { temp_c: 21.0, summary: "Sunny in #{city}" }
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# agent = Agent.new(name: 'weather', model: 'openai/gpt-4o', instructions: 'Answer weather questions.')
|
|
13
|
+
# agent.add_tool :get_weather
|
|
14
|
+
# puts agent.call_sync('Weather in Lisbon?')
|
|
15
|
+
require_relative '../conductor'
|
|
16
|
+
require_relative 'agents/errors'
|
|
17
|
+
require_relative 'agents/runtime/secrets'
|
|
18
|
+
require_relative 'agents/tool'
|
|
19
|
+
require_relative 'agents/tools'
|
|
20
|
+
require_relative 'agents/guardrail'
|
|
21
|
+
require_relative 'agents/termination'
|
|
22
|
+
require_relative 'agents/handoff'
|
|
23
|
+
require_relative 'agents/callback_handler'
|
|
24
|
+
require_relative 'agents/memory'
|
|
25
|
+
require_relative 'agents/prompt_template'
|
|
26
|
+
require_relative 'agents/agent'
|
|
27
|
+
require_relative 'agents/plans'
|
|
28
|
+
require_relative 'agents/config_serializer'
|
|
29
|
+
require_relative 'agents/runtime/agent_config'
|
|
30
|
+
require_relative 'agents/runtime/dispatch'
|
|
31
|
+
require_relative 'agents/runtime/system_workers'
|
|
32
|
+
require_relative 'agents/runtime/tool_registry'
|
|
33
|
+
require_relative 'agents/runtime/execution'
|
|
34
|
+
require_relative 'agents/runtime/approval_request'
|
|
35
|
+
require_relative 'agents/runtime/sse_client'
|
|
36
|
+
require_relative 'agents/runtime/status_poller'
|
|
37
|
+
require_relative 'agents/runtime/agent_runtime'
|
|
38
|
+
|
|
39
|
+
module Conductor
|
|
40
|
+
# Ruby port of the Python SDK's conductor.ai.agents package
|
|
41
|
+
module Agents
|
|
42
|
+
include Tools
|
|
43
|
+
include Secrets
|
|
44
|
+
include Plans
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# Tools and secrets are usable at the module level too (Conductor::Agents.tool ...)
|
|
48
|
+
include Tools
|
|
49
|
+
include Secrets
|
|
50
|
+
include Plans
|
|
51
|
+
|
|
52
|
+
# The default runtime used by Agent#call_sync / #call_async (built from the environment)
|
|
53
|
+
# @return [AgentRuntime]
|
|
54
|
+
def runtime
|
|
55
|
+
@runtime ||= AgentRuntime.new
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
attr_writer :runtime
|
|
59
|
+
|
|
60
|
+
# Replace the default runtime
|
|
61
|
+
# Conductor::Agents.configure(configuration: Conductor::Configuration.new(server_api_url: '...'))
|
|
62
|
+
# @return [AgentRuntime]
|
|
63
|
+
def configure(configuration: nil, agent_config: nil, logger: nil)
|
|
64
|
+
@runtime&.shutdown
|
|
65
|
+
@runtime = AgentRuntime.new(configuration: configuration, agent_config: agent_config, logger: logger)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Stop the default runtime's workers and streams
|
|
69
|
+
def shutdown
|
|
70
|
+
@runtime&.shutdown
|
|
71
|
+
@runtime = nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../exceptions'
|
|
4
|
+
require_relative '../http/api/agent_resource_api'
|
|
5
|
+
|
|
6
|
+
module Conductor
|
|
7
|
+
module Client
|
|
8
|
+
# AgentClient - High-level client for the server-side agent runtime.
|
|
9
|
+
#
|
|
10
|
+
# Mirrors the Python SDK's OrkesAgentClient: hashes in, hashes out, and every
|
|
11
|
+
# transport error is re-raised as AgentApiError (AgentNotFoundError on 404).
|
|
12
|
+
class AgentClient
|
|
13
|
+
attr_reader :agent_api
|
|
14
|
+
|
|
15
|
+
# @param api_client [Http::ApiClient]
|
|
16
|
+
def initialize(api_client)
|
|
17
|
+
@api_client = api_client
|
|
18
|
+
@agent_api = Http::Api::AgentResourceApi.new(api_client)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param payload [Hash] AgentStartRequest
|
|
22
|
+
# @return [Hash] { "executionId", "agentName", "requiredWorkers" }
|
|
23
|
+
def start_agent(payload)
|
|
24
|
+
wrap { @agent_api.start(payload) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Hash] { "agentName", "requiredWorkers" }
|
|
28
|
+
def deploy_agent(payload)
|
|
29
|
+
wrap { @agent_api.deploy(payload) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Hash] { "workflowDef", "requiredWorkers" }
|
|
33
|
+
def compile_agent(payload)
|
|
34
|
+
wrap { @agent_api.compile(payload) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get_status(execution_id)
|
|
38
|
+
wrap { @agent_api.status(execution_id) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Stream raw agent events; runtime consumers can instead use call_async(on_event:).
|
|
42
|
+
def stream_sse(execution_id, last_event_id: nil, &block)
|
|
43
|
+
require_relative '../agents/runtime/sse_client'
|
|
44
|
+
Agents::SseClient.new(@api_client).each_event(execution_id, last_event_id: last_event_id, &block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def get_execution(execution_id)
|
|
48
|
+
wrap { @agent_api.execution(execution_id) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def list_executions(params = {})
|
|
52
|
+
wrap { @agent_api.executions(params) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Respond to a waiting execution. Hashes pass through; anything else is wrapped as
|
|
56
|
+
# { "output" => value } like the Python client does.
|
|
57
|
+
def respond(execution_id, body)
|
|
58
|
+
payload = body.is_a?(Hash) ? body : { 'output' => body }
|
|
59
|
+
wrap { @agent_api.respond(execution_id, payload) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def approve(execution_id)
|
|
63
|
+
respond(execution_id, { 'approved' => true })
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def reject(execution_id, reason = '')
|
|
67
|
+
respond(execution_id, { 'approved' => false, 'reason' => reason.to_s })
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def send_message(execution_id, message)
|
|
71
|
+
respond(execution_id, { 'message' => message.to_s })
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stop(execution_id)
|
|
75
|
+
wrap { @agent_api.stop(execution_id) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def signal(execution_id, message)
|
|
79
|
+
wrap { @agent_api.signal(execution_id, message) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def pause(execution_id)
|
|
83
|
+
wrap { @agent_api.pause(execution_id) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resume(execution_id)
|
|
87
|
+
wrap { @agent_api.resume(execution_id) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def cancel(execution_id, reason: nil)
|
|
91
|
+
wrap { @agent_api.cancel(execution_id, reason: reason) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def list_agents
|
|
95
|
+
wrap { @agent_api.list }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def get_agent(name, version: nil)
|
|
99
|
+
wrap { @agent_api.get_agent(name, version: version) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def delete_agent(name, version: nil)
|
|
103
|
+
wrap { @agent_api.delete(name, version: version) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def wrap
|
|
109
|
+
yield
|
|
110
|
+
rescue AgentApiError
|
|
111
|
+
raise
|
|
112
|
+
rescue ApiError => e
|
|
113
|
+
raise AgentApiError.from_api_error(e)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -45,6 +45,13 @@ module Conductor
|
|
|
45
45
|
@task_api.update_task(task_result)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# Update task status using the v2 endpoint (supports lease extension)
|
|
49
|
+
# @param [TaskResult] task_result Task result
|
|
50
|
+
# @return [Task, nil] Next task for this worker, if any
|
|
51
|
+
def update_task_v2(task_result)
|
|
52
|
+
@task_api.update_task_v2(task_result)
|
|
53
|
+
end
|
|
54
|
+
|
|
48
55
|
# Get task details
|
|
49
56
|
# @param [String] task_id Task ID
|
|
50
57
|
# @return [Task] Task object
|
|
@@ -5,12 +5,43 @@ require_relative 'configuration/authentication_settings'
|
|
|
5
5
|
module Conductor
|
|
6
6
|
# Configuration for Conductor client
|
|
7
7
|
class Configuration
|
|
8
|
-
#
|
|
8
|
+
# Legacy process-wide token cache. Tokens are now cached per Configuration
|
|
9
|
+
# instance so that two configurations (different servers or credentials) in
|
|
10
|
+
# one process never share a token. The class-level accessors remain for one
|
|
11
|
+
# release as a compatibility shim and warn once when used.
|
|
9
12
|
@auth_token = nil
|
|
10
13
|
@token_update_time = 0
|
|
11
14
|
|
|
12
15
|
class << self
|
|
13
|
-
|
|
16
|
+
def auth_token
|
|
17
|
+
legacy_token_cache_warning
|
|
18
|
+
@auth_token
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def auth_token=(token)
|
|
22
|
+
legacy_token_cache_warning
|
|
23
|
+
@auth_token = token
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def token_update_time
|
|
27
|
+
legacy_token_cache_warning
|
|
28
|
+
@token_update_time
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def token_update_time=(time)
|
|
32
|
+
legacy_token_cache_warning
|
|
33
|
+
@token_update_time = time
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def legacy_token_cache_warning
|
|
39
|
+
return if @legacy_token_cache_warned
|
|
40
|
+
|
|
41
|
+
@legacy_token_cache_warned = true
|
|
42
|
+
warn '[Conductor] Configuration.auth_token / token_update_time are deprecated: ' \
|
|
43
|
+
'the auth token is cached per Configuration instance.'
|
|
44
|
+
end
|
|
14
45
|
end
|
|
15
46
|
|
|
16
47
|
attr_accessor :base_url, :server_api_url, :debug, :authentication_settings,
|
|
@@ -29,6 +60,8 @@ module Conductor
|
|
|
29
60
|
@key_file = nil
|
|
30
61
|
@proxy = nil
|
|
31
62
|
@auth_token_ttl_min = auth_token_ttl_min
|
|
63
|
+
@auth_token = nil
|
|
64
|
+
@token_update_time = 0
|
|
32
65
|
|
|
33
66
|
# Resolve server URL
|
|
34
67
|
@host = resolve_host(server_api_url, base_url)
|
|
@@ -50,18 +83,18 @@ module Conductor
|
|
|
50
83
|
@authentication_settings = nil
|
|
51
84
|
end
|
|
52
85
|
|
|
86
|
+
# Cache an auth token on this configuration instance
|
|
87
|
+
# @param token [String] JWT returned by the /token endpoint
|
|
53
88
|
def update_token(token)
|
|
54
|
-
|
|
55
|
-
|
|
89
|
+
@auth_token = token
|
|
90
|
+
@token_update_time = (Time.now.to_f * 1000).to_i
|
|
56
91
|
end
|
|
57
92
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
end
|
|
93
|
+
# @return [String, nil] The cached auth token for this configuration
|
|
94
|
+
attr_reader :auth_token
|
|
61
95
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
end
|
|
96
|
+
# @return [Integer] Epoch milliseconds of the last token update (0 when never set)
|
|
97
|
+
attr_reader :token_update_time
|
|
65
98
|
|
|
66
99
|
# Alias for server URL (used in some places)
|
|
67
100
|
def server_url
|
data/lib/conductor/exceptions.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module Conductor
|
|
4
6
|
# Base exception for all Conductor errors
|
|
5
7
|
class ConductorError < StandardError; end
|
|
@@ -71,6 +73,40 @@ module Conductor
|
|
|
71
73
|
end
|
|
72
74
|
end
|
|
73
75
|
|
|
76
|
+
# Error returned by the agent REST API (/api/agent/*). The server answers 4xx with
|
|
77
|
+
# {"error": "<message>", "status": <code>}; +error+ carries that message when present.
|
|
78
|
+
class AgentApiError < ApiError
|
|
79
|
+
attr_reader :error
|
|
80
|
+
|
|
81
|
+
def initialize(message = nil, status: nil, code: nil, reason: nil, body: nil, headers: nil)
|
|
82
|
+
@error = parse_error(body)
|
|
83
|
+
super(message || @error, status: status, code: code, reason: reason, body: body, headers: headers)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Build from a generic ApiError raised by the transport layer
|
|
87
|
+
# @param error [ApiError]
|
|
88
|
+
# @return [AgentApiError]
|
|
89
|
+
def self.from_api_error(error)
|
|
90
|
+
klass = error.not_found? ? AgentNotFoundError : AgentApiError
|
|
91
|
+
klass.new(error.message, status: error.status, code: error.code, reason: error.reason,
|
|
92
|
+
body: error.body, headers: error.headers)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def parse_error(body)
|
|
98
|
+
return nil unless body.is_a?(String) && !body.empty?
|
|
99
|
+
|
|
100
|
+
data = JSON.parse(body)
|
|
101
|
+
data['error'] if data.is_a?(Hash)
|
|
102
|
+
rescue JSON::ParserError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Agent, execution, or deployment not found (404 from /api/agent/*)
|
|
108
|
+
class AgentNotFoundError < AgentApiError; end
|
|
109
|
+
|
|
74
110
|
# Non-retryable worker error (terminal failure)
|
|
75
111
|
class NonRetryableError < ConductorError; end
|
|
76
112
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../api_client'
|
|
4
|
+
|
|
5
|
+
module Conductor
|
|
6
|
+
module Http
|
|
7
|
+
module Api
|
|
8
|
+
# AgentResourceApi - REST bindings for the server-side agent runtime (/api/agent/*)
|
|
9
|
+
#
|
|
10
|
+
# Every method returns the parsed JSON body as a Hash (or Array) so that callers
|
|
11
|
+
# see exactly the keys the server sent (executionId, requiredWorkers, isComplete, ...).
|
|
12
|
+
# The SSE stream endpoint is not here: it needs a long-lived streaming connection and
|
|
13
|
+
# lives in Conductor::Agents::Runtime::SseClient.
|
|
14
|
+
class AgentResourceApi
|
|
15
|
+
HASH = 'Hash<String, Object>'
|
|
16
|
+
|
|
17
|
+
attr_accessor :api_client
|
|
18
|
+
|
|
19
|
+
def initialize(api_client = nil)
|
|
20
|
+
@api_client = api_client || ApiClient.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Start an agent execution
|
|
24
|
+
# @param body [Hash] AgentStartRequest: agentConfig | name, prompt, sessionId, media, context, runId, ...
|
|
25
|
+
# @return [Hash] { "executionId", "agentName", "requiredWorkers" }
|
|
26
|
+
def start(body)
|
|
27
|
+
post('/agent/start', body)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Register (deploy) an agent definition without starting it
|
|
31
|
+
# @param body [Hash] AgentStartRequest with agentConfig
|
|
32
|
+
# @return [Hash] { "agentName", "requiredWorkers" }
|
|
33
|
+
def deploy(body)
|
|
34
|
+
post('/agent/deploy', body)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Compile an agent config into a workflow definition without registering it
|
|
38
|
+
# @param body [Hash] AgentStartRequest with agentConfig
|
|
39
|
+
# @return [Hash] { "workflowDef", "requiredWorkers" }
|
|
40
|
+
def compile(body)
|
|
41
|
+
post('/agent/compile', body)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get the status of an execution
|
|
45
|
+
# @return [Hash] { "executionId", "status", "isComplete", "isRunning", "isWaiting", "output", "pendingTool", ... }
|
|
46
|
+
def status(execution_id)
|
|
47
|
+
get('/agent/{executionId}/status', execution_id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Get an execution with its tasks and token usage
|
|
51
|
+
# @return [Hash] { "executionId", "status", "output", "tokenUsage", "tasks" }
|
|
52
|
+
def execution(execution_id)
|
|
53
|
+
get('/agent/execution/{executionId}', execution_id)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# List executions
|
|
57
|
+
# @param params [Hash] start, size, sort, freeText, status, agentName, sessionId
|
|
58
|
+
# @return [Hash] { "totalHits", "results" }
|
|
59
|
+
def executions(params = {})
|
|
60
|
+
@api_client.call_api('/agent/executions', 'GET', query_params: params, return_type: HASH,
|
|
61
|
+
return_http_data_only: true)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Respond to a waiting execution (approval, human input, free text)
|
|
65
|
+
# @param body [Hash] e.g. { "approved" => true } or { "approved" => false, "reason" => "..." }
|
|
66
|
+
def respond(execution_id, body)
|
|
67
|
+
post_action(execution_id, 'respond', body)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Ask the agent loop to stop after the current iteration
|
|
71
|
+
def stop(execution_id)
|
|
72
|
+
post_action(execution_id, 'stop')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Inject a signal message into the next LLM turn
|
|
76
|
+
def signal(execution_id, message)
|
|
77
|
+
post_action(execution_id, 'signal', { 'message' => message })
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Pause the execution
|
|
81
|
+
def pause(execution_id)
|
|
82
|
+
@api_client.call_api('/agent/{executionId}/pause', 'PUT', path_params: { executionId: execution_id },
|
|
83
|
+
return_http_data_only: true)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Resume a paused execution
|
|
87
|
+
def resume(execution_id)
|
|
88
|
+
@api_client.call_api('/agent/{executionId}/resume', 'PUT', path_params: { executionId: execution_id },
|
|
89
|
+
return_http_data_only: true)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Cancel (terminate) the execution
|
|
93
|
+
def cancel(execution_id, reason: nil)
|
|
94
|
+
query = reason ? { reason: reason } : {}
|
|
95
|
+
@api_client.call_api('/agent/{executionId}/cancel', 'DELETE', path_params: { executionId: execution_id },
|
|
96
|
+
query_params: query, return_http_data_only: true)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# List deployed agents
|
|
100
|
+
# @return [Array<Hash>]
|
|
101
|
+
def list
|
|
102
|
+
@api_client.call_api('/agent/list', 'GET', return_type: 'Array<Object>', return_http_data_only: true)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Get a deployed agent definition by name
|
|
106
|
+
# @return [Hash] the agentConfig as deployed
|
|
107
|
+
def get_agent(name, version: nil)
|
|
108
|
+
query = version ? { version: version } : {}
|
|
109
|
+
@api_client.call_api('/agent/{name}', 'GET', path_params: { name: name }, query_params: query,
|
|
110
|
+
return_type: HASH, return_http_data_only: true)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Delete a deployed agent definition
|
|
114
|
+
def delete(name, version: nil)
|
|
115
|
+
query = version ? { version: version } : {}
|
|
116
|
+
@api_client.call_api('/agent/{name}', 'DELETE', path_params: { name: name }, query_params: query,
|
|
117
|
+
return_http_data_only: true)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def get(path, execution_id)
|
|
123
|
+
@api_client.call_api(path, 'GET', path_params: { executionId: execution_id }, return_type: HASH,
|
|
124
|
+
return_http_data_only: true)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def post(path, body)
|
|
128
|
+
@api_client.call_api(path, 'POST', body: body, return_type: HASH, return_http_data_only: true)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def post_action(execution_id, action, body = nil)
|
|
132
|
+
@api_client.call_api("/agent/{executionId}/#{action}", 'POST', path_params: { executionId: execution_id },
|
|
133
|
+
body: body, return_http_data_only: true)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|