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,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
require_relative 'tool_call'
|
|
5
|
+
|
|
6
|
+
module Conductor
|
|
7
|
+
module Agents
|
|
8
|
+
# A tool call (or batch of them) waiting for a human decision.
|
|
9
|
+
#
|
|
10
|
+
# agent.on_approval do |request|
|
|
11
|
+
# request.amount < 100 ? request.approve : request.reject('Needs a manager')
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# The server pauses on one HUMAN task per turn, so a request may carry several tool
|
|
15
|
+
# calls; +tool_name+ and the argument accessors (request.amount) read the first one.
|
|
16
|
+
class ApprovalRequest
|
|
17
|
+
attr_reader :execution_id, :task_ref_name, :tool_calls, :response_schema, :raw
|
|
18
|
+
|
|
19
|
+
# @param pending_tool [Hash] the SSE "waiting" event's pendingTool payload
|
|
20
|
+
def initialize(execution_id, pending_tool, client:, execution: nil)
|
|
21
|
+
@execution_id = execution_id
|
|
22
|
+
@client = client
|
|
23
|
+
@execution = execution
|
|
24
|
+
@raw = pending_tool || {}
|
|
25
|
+
@task_ref_name = @raw['taskRefName']
|
|
26
|
+
@response_schema = @raw['response_schema']
|
|
27
|
+
@tool_calls = extract_tool_calls(@raw)
|
|
28
|
+
@responded = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# First tool call's name
|
|
32
|
+
def tool_name
|
|
33
|
+
@tool_calls.first&.name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# First tool call's arguments (String keys)
|
|
37
|
+
def arguments
|
|
38
|
+
@tool_calls.first&.arguments || {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def responded?
|
|
42
|
+
@responded
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Let the tool run
|
|
46
|
+
def approve
|
|
47
|
+
complete_response { @client.approve(@execution_id) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Skip the tool; the run ends COMPLETED with finish_reason :rejected
|
|
51
|
+
def reject(reason = '')
|
|
52
|
+
complete_response { @client.reject(@execution_id, reason) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Free-text answer (human tools / feedback)
|
|
56
|
+
def send_message(message)
|
|
57
|
+
complete_response { @client.send_message(@execution_id, message) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Submit fields requested by response_schema (approval plus reviewer feedback,
|
|
61
|
+
# or structured input for a human tool).
|
|
62
|
+
def respond(body)
|
|
63
|
+
complete_response { @client.respond(@execution_id, body) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# request.amount, request.order_id ... read the first tool call's arguments
|
|
67
|
+
def method_missing(name, *args, &block)
|
|
68
|
+
key = name.to_s
|
|
69
|
+
return arguments[key] if args.empty? && arguments.key?(key)
|
|
70
|
+
|
|
71
|
+
super
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def respond_to_missing?(name, include_private = false)
|
|
75
|
+
arguments.key?(name.to_s) || super
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def to_s
|
|
79
|
+
calls = @tool_calls.map(&:to_s).join(', ')
|
|
80
|
+
"#<Conductor::Agents::ApprovalRequest #{@execution_id} #{calls}>"
|
|
81
|
+
end
|
|
82
|
+
alias inspect to_s
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def complete_response
|
|
87
|
+
raise Error, 'approval request already answered' if @responded
|
|
88
|
+
|
|
89
|
+
yield
|
|
90
|
+
@responded = true
|
|
91
|
+
@execution&.clear_waiting
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def extract_tool_calls(raw)
|
|
96
|
+
calls = raw['toolCalls'] || raw['tool_calls']
|
|
97
|
+
if calls.is_a?(Array) && !calls.empty?
|
|
98
|
+
return calls.map do |c|
|
|
99
|
+
c = c.transform_keys(&:to_s)
|
|
100
|
+
ToolCall.new(name: c['name'], arguments: (c['args'] || c['arguments'] || c['parameters'] || {}).transform_keys(&:to_s))
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
name = raw['tool_name'] || raw['toolName']
|
|
105
|
+
return [] if name.nil?
|
|
106
|
+
|
|
107
|
+
[ToolCall.new(name: name, arguments: (raw['parameters'] || raw['args'] || {}).transform_keys(&:to_s))]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../errors'
|
|
5
|
+
require_relative 'secrets'
|
|
6
|
+
require_relative '../../http/models/task_result'
|
|
7
|
+
|
|
8
|
+
module Conductor
|
|
9
|
+
module Agents
|
|
10
|
+
# Executes one tool task: maps the task's inputData onto the tool method's keyword
|
|
11
|
+
# arguments, runs it, and shapes the result for the server.
|
|
12
|
+
#
|
|
13
|
+
# The server sends the LLM's arguments as top-level keys plus a few injected keys
|
|
14
|
+
# (method, _agent_state, _agent_tool_name, _allowed_commands) that are stripped here.
|
|
15
|
+
# Missing required arguments, missing credentials and unserializable results are
|
|
16
|
+
# terminal failures; anything raised by the tool itself is a retryable failure.
|
|
17
|
+
module Dispatch
|
|
18
|
+
INJECTED_KEYS = %w[method _agent_state _agent_tool_name _allowed_commands __conductor_agent_ctx__].freeze
|
|
19
|
+
WORKER_ID = 'agent-sdk'
|
|
20
|
+
TRUE_STRINGS = %w[true 1 yes].freeze
|
|
21
|
+
FALSE_STRINGS = %w[false 0 no].freeze
|
|
22
|
+
|
|
23
|
+
# Raised when the LLM omitted a required argument
|
|
24
|
+
class MissingArgumentError < Error; end
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @param task [Http::Models::Task]
|
|
29
|
+
# @param tool_def [Tool]
|
|
30
|
+
# @return [Http::Models::TaskResult]
|
|
31
|
+
def run_tool_task(task, tool_def, logger: nil)
|
|
32
|
+
result = base_result(task)
|
|
33
|
+
input = (task.input_data || {}).transform_keys(&:to_s)
|
|
34
|
+
args = input.except(*INJECTED_KEYS)
|
|
35
|
+
|
|
36
|
+
check_credentials!(tool_def)
|
|
37
|
+
kwargs = coerce_args(args, tool_def)
|
|
38
|
+
output = tool_def.func.call(**kwargs)
|
|
39
|
+
return output if output.is_a?(Http::Models::TaskResult)
|
|
40
|
+
|
|
41
|
+
result.status = Http::Models::TaskResultStatus::COMPLETED
|
|
42
|
+
result.output_data = normalize_output(tool_def, output)
|
|
43
|
+
result
|
|
44
|
+
rescue MissingArgumentError, CredentialNotFoundError, ToolSerializationError => e
|
|
45
|
+
logger&.error("tool #{tool_def.name}: #{e.message}")
|
|
46
|
+
terminal_failure(result, e)
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
logger&.error("tool #{tool_def.name} raised #{e.class}: #{e.message}")
|
|
49
|
+
result.status = Http::Models::TaskResultStatus::FAILED
|
|
50
|
+
result.reason_for_incompletion = "#{e.class}: #{e.message}"
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Map input keys to the tool's keyword arguments, coercing by the JSON schema
|
|
55
|
+
# @return [Hash<Symbol, Object>]
|
|
56
|
+
def coerce_args(args, tool_def)
|
|
57
|
+
schema = tool_def.input_schema || {}
|
|
58
|
+
properties = schema['properties'] || {}
|
|
59
|
+
required = Array(schema['required'])
|
|
60
|
+
accepts_rest = tool_def.func.respond_to?(:parameters) && tool_def.func.parameters.any? { |kind, _| kind == :keyrest }
|
|
61
|
+
|
|
62
|
+
missing = required.reject { |name| args.key?(name) }
|
|
63
|
+
raise MissingArgumentError, "tool #{tool_def.name}: missing required argument(s) #{missing.join(', ')}" unless missing.empty?
|
|
64
|
+
|
|
65
|
+
args.each_with_object({}) do |(name, value), kwargs|
|
|
66
|
+
if properties.key?(name)
|
|
67
|
+
kwargs[name.to_sym] = coerce_value(value, properties[name])
|
|
68
|
+
elsif accepts_rest || properties.empty?
|
|
69
|
+
kwargs[name.to_sym] = value
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Coerce one value to its JSON schema type (LLMs often send numbers and JSON as strings)
|
|
75
|
+
def coerce_value(value, property)
|
|
76
|
+
return value unless property.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
type = property['type']
|
|
79
|
+
case type
|
|
80
|
+
when 'integer'
|
|
81
|
+
value.is_a?(String) ? (Integer(value, 10) rescue value) : value # rubocop:disable Style/RescueModifier
|
|
82
|
+
when 'number'
|
|
83
|
+
value.is_a?(String) ? (Float(value) rescue value) : value # rubocop:disable Style/RescueModifier
|
|
84
|
+
when 'boolean'
|
|
85
|
+
return value unless value.is_a?(String)
|
|
86
|
+
|
|
87
|
+
lower = value.strip.downcase
|
|
88
|
+
return true if TRUE_STRINGS.include?(lower)
|
|
89
|
+
return false if FALSE_STRINGS.include?(lower)
|
|
90
|
+
|
|
91
|
+
value
|
|
92
|
+
when 'array', 'object'
|
|
93
|
+
return value unless value.is_a?(String)
|
|
94
|
+
|
|
95
|
+
parsed = JSON.parse(value)
|
|
96
|
+
expected = type == 'array' ? Array : Hash
|
|
97
|
+
parsed.is_a?(expected) ? parsed : value
|
|
98
|
+
when 'string'
|
|
99
|
+
value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value
|
|
100
|
+
else
|
|
101
|
+
value
|
|
102
|
+
end
|
|
103
|
+
rescue JSON::ParserError
|
|
104
|
+
value
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Hash results go out as-is; anything else is wrapped as { "result" => value }
|
|
108
|
+
def normalize_output(tool_def, output)
|
|
109
|
+
data = output.is_a?(Hash) ? output.transform_keys(&:to_s) : { 'result' => output }
|
|
110
|
+
begin
|
|
111
|
+
JSON.generate(data)
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
raise ToolSerializationError, "tool #{tool_def.name} returned a non-JSON-serializable result: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
data
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def check_credentials!(tool_def)
|
|
119
|
+
tool_def.credentials.each { |name| Secrets.secret(name) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def base_result(task)
|
|
123
|
+
result = Http::Models::TaskResult.new
|
|
124
|
+
result.task_id = task.task_id
|
|
125
|
+
result.workflow_instance_id = task.workflow_instance_id
|
|
126
|
+
result.worker_id = WORKER_ID
|
|
127
|
+
result
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def terminal_failure(result, error)
|
|
131
|
+
result.status = Http::Models::TaskResultStatus::FAILED_WITH_TERMINAL_ERROR
|
|
132
|
+
result.reason_for_incompletion = error.message
|
|
133
|
+
result
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
require_relative '../errors'
|
|
5
|
+
require_relative 'approval_request'
|
|
6
|
+
|
|
7
|
+
module Conductor
|
|
8
|
+
module Agents
|
|
9
|
+
# Token usage for an execution (summed over sub-agent executions)
|
|
10
|
+
TokenUsage = Struct.new(:prompt_tokens, :completion_tokens, :total_tokens, keyword_init: true) do
|
|
11
|
+
def initialize(prompt_tokens: 0, completion_tokens: 0, total_tokens: 0)
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def +(other)
|
|
16
|
+
TokenUsage.new(prompt_tokens: prompt_tokens + other.prompt_tokens,
|
|
17
|
+
completion_tokens: completion_tokens + other.completion_tokens,
|
|
18
|
+
total_tokens: total_tokens + other.total_tokens)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Maps the server's status + output.finishReason to a Symbol
|
|
23
|
+
module FinishReason
|
|
24
|
+
def self.derive(status, output)
|
|
25
|
+
case status.to_s
|
|
26
|
+
when 'COMPLETED'
|
|
27
|
+
fr = output.is_a?(Hash) ? output['finishReason'].to_s : ''
|
|
28
|
+
case fr
|
|
29
|
+
when 'rejected' then :rejected
|
|
30
|
+
when 'LENGTH', 'MAX_TOKENS' then :length
|
|
31
|
+
when 'tool_calls', 'TOOL_CALLS' then :tool_calls
|
|
32
|
+
when 'CONTENT_FILTER' then :content_filter
|
|
33
|
+
else :stop
|
|
34
|
+
end
|
|
35
|
+
when 'FAILED' then :error
|
|
36
|
+
when 'TERMINATED' then :cancelled
|
|
37
|
+
when 'TIMED_OUT' then :timeout
|
|
38
|
+
else :stop
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Handle on a running (or finished) agent execution.
|
|
44
|
+
#
|
|
45
|
+
# execution = agent.call_async('Refund order A-1029')
|
|
46
|
+
# execution.done? # false until finished
|
|
47
|
+
# execution.partial_text # streamed text so far
|
|
48
|
+
# execution.result # blocks for the answer
|
|
49
|
+
# execution.finish_reason # :stop | :rejected | ...
|
|
50
|
+
class Execution
|
|
51
|
+
TERMINAL_STATUSES = %w[COMPLETED FAILED TERMINATED TIMED_OUT].freeze
|
|
52
|
+
|
|
53
|
+
attr_reader :execution_id, :agent_name, :tool_calls, :token_usage, :pending, :error, :status, :output, :events
|
|
54
|
+
|
|
55
|
+
# @param execution_id [String] also the Conductor workflow id
|
|
56
|
+
# @param client [Client::AgentClient]
|
|
57
|
+
def initialize(execution_id, client:, agent_name: nil, runtime: nil)
|
|
58
|
+
@execution_id = execution_id
|
|
59
|
+
@client = client
|
|
60
|
+
@agent_name = agent_name
|
|
61
|
+
@runtime = runtime
|
|
62
|
+
@mutex = Mutex.new
|
|
63
|
+
@done_cv = ConditionVariable.new
|
|
64
|
+
@partial_text = +''
|
|
65
|
+
@tool_calls = []
|
|
66
|
+
@events = []
|
|
67
|
+
@token_usage = TokenUsage.new
|
|
68
|
+
@pending = nil
|
|
69
|
+
@status = 'RUNNING'
|
|
70
|
+
@output = nil
|
|
71
|
+
@result = nil
|
|
72
|
+
@error = nil
|
|
73
|
+
@waiting = false
|
|
74
|
+
@finished = false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Look up an execution by id (a snapshot; +result+ attaches a stream if still running)
|
|
78
|
+
# @return [Execution]
|
|
79
|
+
def self.find(execution_id, runtime: Conductor::Agents.runtime)
|
|
80
|
+
execution = new(execution_id, client: runtime.client, runtime: runtime)
|
|
81
|
+
execution.refresh!
|
|
82
|
+
execution
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Re-read status from the server
|
|
86
|
+
def refresh!
|
|
87
|
+
status = @client.get_status(@execution_id)
|
|
88
|
+
@agent_name ||= status['agentName']
|
|
89
|
+
if status['isComplete']
|
|
90
|
+
finish(status: status['status'], output: status['output'], reason: status['reasonForIncompletion'])
|
|
91
|
+
elsif status['isWaiting']
|
|
92
|
+
mark_waiting(ApprovalRequest.new(@execution_id, status['pendingTool'], client: @client, execution: self))
|
|
93
|
+
else
|
|
94
|
+
clear_waiting
|
|
95
|
+
end
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def done?
|
|
100
|
+
@mutex.synchronize { @finished }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def waiting?
|
|
104
|
+
@mutex.synchronize { @waiting && !@finished }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Text streamed so far (never behind +result+ once done)
|
|
108
|
+
def partial_text
|
|
109
|
+
@mutex.synchronize { @partial_text.dup }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Block until finished and return the answer
|
|
113
|
+
# @param timeout [Numeric, nil] seconds; nil waits forever
|
|
114
|
+
# @raise [Error] when the execution failed, was cancelled or timed out on the server
|
|
115
|
+
# @raise [Timeout::Error] when +timeout+ elapses first
|
|
116
|
+
def result(timeout: nil)
|
|
117
|
+
@runtime&.attach(self) unless done? || @attached
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
deadline = timeout && (Time.now + timeout)
|
|
120
|
+
until @finished
|
|
121
|
+
remaining = deadline && (deadline - Time.now)
|
|
122
|
+
raise Timeout::Error, "execution #{@execution_id} still running after #{timeout}s" if remaining && remaining <= 0
|
|
123
|
+
|
|
124
|
+
@done_cv.wait(@mutex, remaining)
|
|
125
|
+
end
|
|
126
|
+
raise Error, "execution #{@execution_id} #{@status.downcase}: #{@error}" if @error && @status != 'COMPLETED'
|
|
127
|
+
|
|
128
|
+
@result
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The answer without blocking or raising (nil while running or when failed)
|
|
133
|
+
def answer
|
|
134
|
+
@mutex.synchronize { @finished ? @result : nil }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @return [Symbol] :stop | :tool_calls | :length | :content_filter | :rejected | :error | :cancelled | :timeout | nil
|
|
138
|
+
def finish_reason
|
|
139
|
+
@mutex.synchronize { @finished ? FinishReason.derive(@status, @output) : nil }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def rejected?
|
|
143
|
+
finish_reason == :rejected
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ── control ─────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def pause
|
|
149
|
+
@client.pause(@execution_id)
|
|
150
|
+
self
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def resume
|
|
154
|
+
@client.resume(@execution_id)
|
|
155
|
+
self
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def cancel(reason: 'cancelled by client')
|
|
159
|
+
@client.cancel(@execution_id, reason: reason)
|
|
160
|
+
self
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def stop
|
|
164
|
+
@client.stop(@execution_id)
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def signal(message)
|
|
169
|
+
@client.signal(@execution_id, message)
|
|
170
|
+
self
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Approve / reject the pending tool call, if any
|
|
174
|
+
def approve
|
|
175
|
+
(pending || raise(Error, 'nothing is waiting for approval')).approve
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def reject(reason = '')
|
|
179
|
+
(pending || raise(Error, 'nothing is waiting for approval')).reject(reason)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# ── mutators used by the runtime's stream thread ────────────────
|
|
183
|
+
|
|
184
|
+
def attached!
|
|
185
|
+
@attached = true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def record_event(event)
|
|
189
|
+
@mutex.synchronize { @events << event }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def append_text(text)
|
|
193
|
+
return if text.nil? || text.to_s.empty?
|
|
194
|
+
|
|
195
|
+
@mutex.synchronize { @partial_text << text.to_s }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def add_tool_call(name, arguments)
|
|
199
|
+
@mutex.synchronize { @tool_calls << ToolCall.new(name: name, arguments: arguments || {}) }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def add_tool_result(name, result)
|
|
203
|
+
@mutex.synchronize do
|
|
204
|
+
call = @tool_calls.reverse.find { |c| c.name == name && c.result.nil? }
|
|
205
|
+
call ? call.result = result : @tool_calls << ToolCall.new(name: name, arguments: {}, result: result)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def mark_waiting(pending)
|
|
210
|
+
@mutex.synchronize do
|
|
211
|
+
@waiting = true
|
|
212
|
+
@pending = pending
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def clear_waiting
|
|
217
|
+
@mutex.synchronize do
|
|
218
|
+
@waiting = false
|
|
219
|
+
@pending = nil
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def token_usage=(usage)
|
|
224
|
+
@mutex.synchronize { @token_usage = usage }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Mark the execution finished. +output+ is the workflow output ({result, finishReason, ...}).
|
|
228
|
+
def finish(status:, output:, reason: nil)
|
|
229
|
+
@mutex.synchronize do
|
|
230
|
+
return if @finished
|
|
231
|
+
|
|
232
|
+
@status = status.to_s
|
|
233
|
+
@output = output.is_a?(Hash) ? output : {}
|
|
234
|
+
@result = extract_result(output)
|
|
235
|
+
@error = reason if reason && !reason.to_s.empty?
|
|
236
|
+
@error ||= (@output['error'] || @output['reason']) unless @status == 'COMPLETED'
|
|
237
|
+
@error ||= "execution #{@status.downcase}" unless @status == 'COMPLETED'
|
|
238
|
+
@partial_text = @result.to_s.dup if @result.is_a?(String) && @partial_text.empty?
|
|
239
|
+
@waiting = false
|
|
240
|
+
@pending = nil
|
|
241
|
+
@finished = true
|
|
242
|
+
@done_cv.broadcast
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def fail(reason)
|
|
247
|
+
finish(status: 'FAILED', output: { 'error' => reason }, reason: reason)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def to_s
|
|
251
|
+
"#<Conductor::Agents::Execution #{@execution_id} #{@status}#{@finished ? '' : ' (running)'}>"
|
|
252
|
+
end
|
|
253
|
+
alias inspect to_s
|
|
254
|
+
|
|
255
|
+
private
|
|
256
|
+
|
|
257
|
+
def extract_result(output)
|
|
258
|
+
return output unless output.is_a?(Hash)
|
|
259
|
+
return output['result'] if output.key?('result')
|
|
260
|
+
|
|
261
|
+
output
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
require_relative '../../worker/task_context'
|
|
5
|
+
|
|
6
|
+
module Conductor
|
|
7
|
+
module Agents
|
|
8
|
+
# Read secrets inside a tool body.
|
|
9
|
+
#
|
|
10
|
+
# tool def create_issue(title: String)
|
|
11
|
+
# Github.create_issue(title, token: secret('GH_TOKEN'))
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# The literal name is also the declaration: the Tools DSL scans the body and puts
|
|
15
|
+
# GH_TOKEN on the tool's TaskDef#runtime_metadata. The server resolves it from its
|
|
16
|
+
# secret store at poll time and delivers the value on Task#runtime_metadata, which
|
|
17
|
+
# TaskContext (thread/fiber-local) exposes to the running tool. Nothing is ever written
|
|
18
|
+
# to ENV; for subprocesses use secrets_env.
|
|
19
|
+
module Secrets
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param name [String, Symbol]
|
|
23
|
+
# @return [String]
|
|
24
|
+
# @raise [CredentialNotFoundError] when neither the task nor ENV has it
|
|
25
|
+
def secret(name)
|
|
26
|
+
key = name.to_s
|
|
27
|
+
value = task_secrets[key]
|
|
28
|
+
value = ENV.fetch(key, nil) if value.nil?
|
|
29
|
+
return value unless value.nil?
|
|
30
|
+
|
|
31
|
+
raise CredentialNotFoundError,
|
|
32
|
+
"secret #{key.inspect} not found: it was not delivered on the task's runtimeMetadata " \
|
|
33
|
+
"and ENV[#{key.inspect}] is unset. Store it on the server (conductor secrets put #{key} ...) " \
|
|
34
|
+
'or declare it with add_tool ..., credentials: [...]'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Environment hash for system / spawn / Open3: { 'GH_TOKEN' => '...' }
|
|
38
|
+
# @return [Hash<String, String>]
|
|
39
|
+
def secrets_env(*names)
|
|
40
|
+
names.flatten.each_with_object({}) { |n, env| env[n.to_s] = secret(n) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Secrets bound for the current task (wire-only Task#runtime_metadata)
|
|
44
|
+
# @return [Hash<String, String>]
|
|
45
|
+
def task_secrets
|
|
46
|
+
ctx = Conductor::Worker::TaskContext.current
|
|
47
|
+
task = ctx&.task
|
|
48
|
+
return {} unless task.respond_to?(:runtime_metadata)
|
|
49
|
+
|
|
50
|
+
task.runtime_metadata || {}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|