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,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
- # Class-level auth token cache (shared across instances, like Python SDK)
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
- attr_accessor :auth_token, :token_update_time
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
- self.class.auth_token = token
55
- self.class.token_update_time = (Time.now.to_f * 1000).to_i
89
+ @auth_token = token
90
+ @token_update_time = (Time.now.to_f * 1000).to_i
56
91
  end
57
92
 
58
- def auth_token
59
- self.class.auth_token
60
- end
93
+ # @return [String, nil] The cached auth token for this configuration
94
+ attr_reader :auth_token
61
95
 
62
- def token_update_time
63
- self.class.token_update_time
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
@@ -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