language-operator 0.0.1 → 0.1.30
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/.rubocop.yml +125 -0
- data/CHANGELOG.md +53 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +284 -0
- data/LICENSE +229 -21
- data/Makefile +77 -0
- data/README.md +3 -11
- data/Rakefile +34 -0
- data/bin/aictl +7 -0
- data/completions/_aictl +232 -0
- data/completions/aictl.bash +121 -0
- data/completions/aictl.fish +114 -0
- data/docs/architecture/agent-runtime.md +585 -0
- data/docs/dsl/agent-reference.md +591 -0
- data/docs/dsl/best-practices.md +1078 -0
- data/docs/dsl/chat-endpoints.md +895 -0
- data/docs/dsl/constraints.md +671 -0
- data/docs/dsl/mcp-integration.md +1177 -0
- data/docs/dsl/webhooks.md +932 -0
- data/docs/dsl/workflows.md +744 -0
- data/examples/README.md +569 -0
- data/examples/agent_example.rb +86 -0
- data/examples/chat_endpoint_agent.rb +118 -0
- data/examples/github_webhook_agent.rb +171 -0
- data/examples/mcp_agent.rb +158 -0
- data/examples/oauth_callback_agent.rb +296 -0
- data/examples/stripe_webhook_agent.rb +219 -0
- data/examples/webhook_agent.rb +80 -0
- data/lib/language_operator/agent/base.rb +110 -0
- data/lib/language_operator/agent/executor.rb +440 -0
- data/lib/language_operator/agent/instrumentation.rb +54 -0
- data/lib/language_operator/agent/metrics_tracker.rb +183 -0
- data/lib/language_operator/agent/safety/ast_validator.rb +272 -0
- data/lib/language_operator/agent/safety/audit_logger.rb +104 -0
- data/lib/language_operator/agent/safety/budget_tracker.rb +175 -0
- data/lib/language_operator/agent/safety/content_filter.rb +93 -0
- data/lib/language_operator/agent/safety/manager.rb +207 -0
- data/lib/language_operator/agent/safety/rate_limiter.rb +150 -0
- data/lib/language_operator/agent/safety/safe_executor.rb +115 -0
- data/lib/language_operator/agent/scheduler.rb +183 -0
- data/lib/language_operator/agent/telemetry.rb +116 -0
- data/lib/language_operator/agent/web_server.rb +610 -0
- data/lib/language_operator/agent/webhook_authenticator.rb +226 -0
- data/lib/language_operator/agent.rb +149 -0
- data/lib/language_operator/cli/commands/agent.rb +1252 -0
- data/lib/language_operator/cli/commands/cluster.rb +335 -0
- data/lib/language_operator/cli/commands/install.rb +404 -0
- data/lib/language_operator/cli/commands/model.rb +266 -0
- data/lib/language_operator/cli/commands/persona.rb +396 -0
- data/lib/language_operator/cli/commands/quickstart.rb +22 -0
- data/lib/language_operator/cli/commands/status.rb +156 -0
- data/lib/language_operator/cli/commands/tool.rb +537 -0
- data/lib/language_operator/cli/commands/use.rb +47 -0
- data/lib/language_operator/cli/errors/handler.rb +180 -0
- data/lib/language_operator/cli/errors/suggestions.rb +176 -0
- data/lib/language_operator/cli/formatters/code_formatter.rb +81 -0
- data/lib/language_operator/cli/formatters/log_formatter.rb +290 -0
- data/lib/language_operator/cli/formatters/progress_formatter.rb +53 -0
- data/lib/language_operator/cli/formatters/table_formatter.rb +179 -0
- data/lib/language_operator/cli/formatters/value_formatter.rb +113 -0
- data/lib/language_operator/cli/helpers/cluster_context.rb +62 -0
- data/lib/language_operator/cli/helpers/cluster_validator.rb +101 -0
- data/lib/language_operator/cli/helpers/editor_helper.rb +58 -0
- data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +167 -0
- data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +74 -0
- data/lib/language_operator/cli/helpers/schedule_builder.rb +108 -0
- data/lib/language_operator/cli/helpers/user_prompts.rb +69 -0
- data/lib/language_operator/cli/main.rb +232 -0
- data/lib/language_operator/cli/templates/tools/generic.yaml +66 -0
- data/lib/language_operator/cli/wizards/agent_wizard.rb +246 -0
- data/lib/language_operator/cli/wizards/quickstart_wizard.rb +588 -0
- data/lib/language_operator/client/base.rb +214 -0
- data/lib/language_operator/client/config.rb +136 -0
- data/lib/language_operator/client/cost_calculator.rb +37 -0
- data/lib/language_operator/client/mcp_connector.rb +123 -0
- data/lib/language_operator/client.rb +19 -0
- data/lib/language_operator/config/cluster_config.rb +101 -0
- data/lib/language_operator/config/tool_patterns.yaml +57 -0
- data/lib/language_operator/config/tool_registry.rb +96 -0
- data/lib/language_operator/config.rb +138 -0
- data/lib/language_operator/dsl/adapter.rb +124 -0
- data/lib/language_operator/dsl/agent_context.rb +90 -0
- data/lib/language_operator/dsl/agent_definition.rb +427 -0
- data/lib/language_operator/dsl/chat_endpoint_definition.rb +115 -0
- data/lib/language_operator/dsl/config.rb +119 -0
- data/lib/language_operator/dsl/context.rb +50 -0
- data/lib/language_operator/dsl/execution_context.rb +47 -0
- data/lib/language_operator/dsl/helpers.rb +109 -0
- data/lib/language_operator/dsl/http.rb +184 -0
- data/lib/language_operator/dsl/mcp_server_definition.rb +73 -0
- data/lib/language_operator/dsl/parameter_definition.rb +124 -0
- data/lib/language_operator/dsl/registry.rb +36 -0
- data/lib/language_operator/dsl/shell.rb +125 -0
- data/lib/language_operator/dsl/tool_definition.rb +112 -0
- data/lib/language_operator/dsl/webhook_authentication.rb +114 -0
- data/lib/language_operator/dsl/webhook_definition.rb +106 -0
- data/lib/language_operator/dsl/workflow_definition.rb +259 -0
- data/lib/language_operator/dsl.rb +160 -0
- data/lib/language_operator/errors.rb +60 -0
- data/lib/language_operator/kubernetes/client.rb +279 -0
- data/lib/language_operator/kubernetes/resource_builder.rb +194 -0
- data/lib/language_operator/loggable.rb +47 -0
- data/lib/language_operator/logger.rb +141 -0
- data/lib/language_operator/retry.rb +123 -0
- data/lib/language_operator/retryable.rb +132 -0
- data/lib/language_operator/tool_loader.rb +242 -0
- data/lib/language_operator/validators.rb +170 -0
- data/lib/language_operator/version.rb +1 -1
- data/lib/language_operator.rb +65 -3
- data/requirements/tasks/challenge.md +9 -0
- data/requirements/tasks/iterate.md +36 -0
- data/requirements/tasks/optimize.md +21 -0
- data/requirements/tasks/tag.md +5 -0
- data/test_agent_dsl.rb +108 -0
- metadata +503 -20
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LanguageOperator
|
|
4
|
+
# Mixin module to provide automatic logger initialization for classes
|
|
5
|
+
# that need logging capabilities.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# class MyClass
|
|
9
|
+
# include LanguageOperator::Loggable
|
|
10
|
+
#
|
|
11
|
+
# def process
|
|
12
|
+
# logger.info("Processing started")
|
|
13
|
+
# # ...
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# The logger component name is automatically derived from the class name.
|
|
18
|
+
# You can override the component name by defining a `logger_component` method.
|
|
19
|
+
#
|
|
20
|
+
# @example Custom component name
|
|
21
|
+
# class MyClass
|
|
22
|
+
# include LanguageOperator::Loggable
|
|
23
|
+
#
|
|
24
|
+
# def logger_component
|
|
25
|
+
# 'CustomName'
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
module Loggable
|
|
29
|
+
# Returns a logger instance for this class.
|
|
30
|
+
# Lazily initializes the logger on first access.
|
|
31
|
+
#
|
|
32
|
+
# @return [LanguageOperator::Logger] Logger instance
|
|
33
|
+
def logger
|
|
34
|
+
@logger ||= LanguageOperator::Logger.new(component: logger_component)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Returns the component name to use for the logger.
|
|
40
|
+
# Defaults to the class name, but can be overridden.
|
|
41
|
+
#
|
|
42
|
+
# @return [String] Component name for the logger
|
|
43
|
+
def logger_component
|
|
44
|
+
self.class.name
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module LanguageOperator
|
|
6
|
+
# Structured logger with configurable output formats and levels
|
|
7
|
+
#
|
|
8
|
+
# Supports multiple output formats:
|
|
9
|
+
# - :pretty (default): Human-readable with emojis and colors
|
|
10
|
+
# - :text: Plain text with timestamps
|
|
11
|
+
# - :json: Structured JSON output
|
|
12
|
+
#
|
|
13
|
+
# Environment variables:
|
|
14
|
+
# - LOG_LEVEL: DEBUG, INFO, WARN, ERROR (default: INFO)
|
|
15
|
+
# - LOG_FORMAT: pretty, text, json (default: pretty)
|
|
16
|
+
# - LOG_TIMING: true/false - Include operation timing (default: true)
|
|
17
|
+
class Logger
|
|
18
|
+
LEVELS = {
|
|
19
|
+
'DEBUG' => ::Logger::DEBUG,
|
|
20
|
+
'INFO' => ::Logger::INFO,
|
|
21
|
+
'WARN' => ::Logger::WARN,
|
|
22
|
+
'ERROR' => ::Logger::ERROR
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
LEVEL_EMOJI = {
|
|
26
|
+
'DEBUG' => '🔍',
|
|
27
|
+
'INFO' => 'ℹ️ ',
|
|
28
|
+
'WARN' => '⚠️ ',
|
|
29
|
+
'ERROR' => '❌'
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :logger, :format, :show_timing
|
|
33
|
+
|
|
34
|
+
def initialize(component: 'Langop', format: nil, level: nil)
|
|
35
|
+
@component = component
|
|
36
|
+
@format = format || ENV.fetch('LOG_FORMAT', 'pretty').to_sym
|
|
37
|
+
@show_timing = ENV.fetch('LOG_TIMING', 'true') == 'true'
|
|
38
|
+
|
|
39
|
+
log_level_name = level || ENV.fetch('LOG_LEVEL', 'INFO')
|
|
40
|
+
log_level = LEVELS[log_level_name.upcase] || ::Logger::INFO
|
|
41
|
+
|
|
42
|
+
@logger = ::Logger.new($stdout)
|
|
43
|
+
@logger.level = log_level
|
|
44
|
+
@logger.formatter = method(:format_message)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def debug(message, **metadata)
|
|
48
|
+
log(:debug, message, **metadata)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def info(message, **metadata)
|
|
52
|
+
log(:info, message, **metadata)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def warn(message, **metadata)
|
|
56
|
+
log(:warn, message, **metadata)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def error(message, **metadata)
|
|
60
|
+
log(:error, message, **metadata)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Log with timing information
|
|
64
|
+
def timed(message, **metadata)
|
|
65
|
+
start_time = Time.now
|
|
66
|
+
result = yield if block_given?
|
|
67
|
+
duration = Time.now - start_time
|
|
68
|
+
|
|
69
|
+
info(message, **metadata, duration_s: duration.round(3))
|
|
70
|
+
result
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def log(severity, message, **metadata)
|
|
76
|
+
@logger.send(severity) do
|
|
77
|
+
case @format
|
|
78
|
+
when :json
|
|
79
|
+
format_json(severity, message, **metadata)
|
|
80
|
+
when :text
|
|
81
|
+
format_text(severity, message, **metadata)
|
|
82
|
+
else
|
|
83
|
+
format_pretty(severity, message, **metadata)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def format_message(_severity, _timestamp, _progname, msg)
|
|
89
|
+
"#{msg}\n" # Already formatted by log method, add newline
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def format_json(severity, message, **metadata)
|
|
93
|
+
require 'json'
|
|
94
|
+
JSON.generate({
|
|
95
|
+
timestamp: Time.now.iso8601,
|
|
96
|
+
level: severity.to_s.upcase,
|
|
97
|
+
component: @component,
|
|
98
|
+
message: message,
|
|
99
|
+
**metadata
|
|
100
|
+
})
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def format_text(severity, message, **metadata)
|
|
104
|
+
parts = [
|
|
105
|
+
Time.now.strftime('%Y-%m-%d %H:%M:%S'),
|
|
106
|
+
severity.to_s.upcase.ljust(5),
|
|
107
|
+
"[#{@component}]",
|
|
108
|
+
message
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
metadata_str = format_metadata(**metadata)
|
|
112
|
+
parts << metadata_str unless metadata_str.empty?
|
|
113
|
+
|
|
114
|
+
parts.join(' ')
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def format_pretty(severity, message, **metadata)
|
|
118
|
+
emoji = LEVEL_EMOJI[severity.to_s.upcase] || '•'
|
|
119
|
+
parts = [emoji, message]
|
|
120
|
+
|
|
121
|
+
metadata_str = format_metadata(**metadata)
|
|
122
|
+
parts << "(#{metadata_str})" unless metadata_str.empty?
|
|
123
|
+
|
|
124
|
+
parts.join(' ')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def format_metadata(**metadata)
|
|
128
|
+
return '' if metadata.empty?
|
|
129
|
+
|
|
130
|
+
metadata.map do |key, value|
|
|
131
|
+
if key == :duration_s && @show_timing
|
|
132
|
+
"#{value}s"
|
|
133
|
+
elsif value.is_a?(String) && value.length > 100
|
|
134
|
+
"#{key}=#{value[0..97]}..."
|
|
135
|
+
else
|
|
136
|
+
"#{key}=#{value}"
|
|
137
|
+
end
|
|
138
|
+
end.join(', ')
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LanguageOperator
|
|
4
|
+
# Retry utilities with exponential backoff for handling transient failures
|
|
5
|
+
module Retry
|
|
6
|
+
# Default retry configuration
|
|
7
|
+
DEFAULT_MAX_RETRIES = 3
|
|
8
|
+
DEFAULT_BASE_DELAY = 1.0
|
|
9
|
+
DEFAULT_MAX_DELAY = 10.0
|
|
10
|
+
DEFAULT_JITTER_FACTOR = 0.1
|
|
11
|
+
|
|
12
|
+
# Common retryable HTTP status codes (transient errors)
|
|
13
|
+
RETRYABLE_HTTP_CODES = [429, 500, 502, 503, 504].freeze
|
|
14
|
+
|
|
15
|
+
# Execute a block with exponential backoff retry logic
|
|
16
|
+
# @param max_retries [Integer] Maximum number of retry attempts (default: 3)
|
|
17
|
+
# @param base_delay [Float] Initial delay in seconds (default: 1.0)
|
|
18
|
+
# @param max_delay [Float] Maximum delay cap in seconds (default: 10.0)
|
|
19
|
+
# @param jitter_factor [Float] Jitter randomization factor (default: 0.1)
|
|
20
|
+
# @param on_retry [Proc] Optional callback called before each retry (receives attempt number and exception)
|
|
21
|
+
# @yield Block to execute with retry logic
|
|
22
|
+
# @return [Object] Return value of the block
|
|
23
|
+
# @raise [StandardError] Re-raises the exception if all retries are exhausted
|
|
24
|
+
#
|
|
25
|
+
# @example Basic usage
|
|
26
|
+
# LanguageOperator::Retry.with_backoff(max_retries: 5) do
|
|
27
|
+
# client.get_resource(name)
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# @example With callback
|
|
31
|
+
# LanguageOperator::Retry.with_backoff(on_retry: ->(attempt, e) {
|
|
32
|
+
# puts "Retry attempt #{attempt} after error: #{e.message}"
|
|
33
|
+
# }) do
|
|
34
|
+
# api_call
|
|
35
|
+
# end
|
|
36
|
+
def self.with_backoff(max_retries: DEFAULT_MAX_RETRIES,
|
|
37
|
+
base_delay: DEFAULT_BASE_DELAY,
|
|
38
|
+
max_delay: DEFAULT_MAX_DELAY,
|
|
39
|
+
jitter_factor: DEFAULT_JITTER_FACTOR,
|
|
40
|
+
on_retry: nil)
|
|
41
|
+
attempt = 0
|
|
42
|
+
begin
|
|
43
|
+
yield
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
if attempt < max_retries
|
|
46
|
+
attempt += 1
|
|
47
|
+
delay = calculate_backoff(attempt, base_delay, max_delay, jitter_factor)
|
|
48
|
+
on_retry&.call(attempt, e)
|
|
49
|
+
sleep delay
|
|
50
|
+
retry
|
|
51
|
+
end
|
|
52
|
+
raise e
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Execute a block with retry for specific exception types
|
|
57
|
+
# @param exception_types [Array<Class>] Exception types to retry on
|
|
58
|
+
# @param max_retries [Integer] Maximum number of retry attempts
|
|
59
|
+
# @param base_delay [Float] Initial delay in seconds
|
|
60
|
+
# @param max_delay [Float] Maximum delay cap in seconds
|
|
61
|
+
# @param jitter_factor [Float] Jitter randomization factor
|
|
62
|
+
# @yield Block to execute
|
|
63
|
+
# @return [Object] Return value of the block
|
|
64
|
+
# @raise [StandardError] Re-raises the exception if all retries are exhausted
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# LanguageOperator::Retry.on_exceptions([Net::OpenTimeout, Errno::ECONNREFUSED]) do
|
|
68
|
+
# smtp.connect
|
|
69
|
+
# end
|
|
70
|
+
def self.on_exceptions(exception_types, max_retries: DEFAULT_MAX_RETRIES,
|
|
71
|
+
base_delay: DEFAULT_BASE_DELAY,
|
|
72
|
+
max_delay: DEFAULT_MAX_DELAY,
|
|
73
|
+
jitter_factor: DEFAULT_JITTER_FACTOR)
|
|
74
|
+
attempt = 0
|
|
75
|
+
begin
|
|
76
|
+
yield
|
|
77
|
+
rescue *exception_types => e
|
|
78
|
+
if attempt < max_retries
|
|
79
|
+
attempt += 1
|
|
80
|
+
delay = calculate_backoff(attempt, base_delay, max_delay, jitter_factor)
|
|
81
|
+
sleep delay
|
|
82
|
+
retry
|
|
83
|
+
end
|
|
84
|
+
raise e
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Check if an HTTP status code is retryable (transient error)
|
|
89
|
+
# @param status [Integer] HTTP status code
|
|
90
|
+
# @return [Boolean] True if status code indicates a transient error
|
|
91
|
+
#
|
|
92
|
+
# @example
|
|
93
|
+
# LanguageOperator::Retry.retryable_http_code?(503) # => true
|
|
94
|
+
# LanguageOperator::Retry.retryable_http_code?(404) # => false
|
|
95
|
+
def self.retryable_http_code?(status)
|
|
96
|
+
RETRYABLE_HTTP_CODES.include?(status)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Calculate exponential backoff delay with jitter
|
|
100
|
+
# @param attempt [Integer] Retry attempt number (1-based)
|
|
101
|
+
# @param base_delay [Float] Initial delay in seconds
|
|
102
|
+
# @param max_delay [Float] Maximum delay cap in seconds
|
|
103
|
+
# @param jitter_factor [Float] Jitter randomization factor (0.0 to 1.0)
|
|
104
|
+
# @return [Float] Delay in seconds
|
|
105
|
+
#
|
|
106
|
+
# @example
|
|
107
|
+
# LanguageOperator::Retry.calculate_backoff(1) # => ~1.0 seconds
|
|
108
|
+
# LanguageOperator::Retry.calculate_backoff(2) # => ~2.0 seconds
|
|
109
|
+
# LanguageOperator::Retry.calculate_backoff(3) # => ~4.0 seconds
|
|
110
|
+
def self.calculate_backoff(attempt,
|
|
111
|
+
base_delay = DEFAULT_BASE_DELAY,
|
|
112
|
+
max_delay = DEFAULT_MAX_DELAY,
|
|
113
|
+
jitter_factor = DEFAULT_JITTER_FACTOR)
|
|
114
|
+
# Exponential: base * 2^(attempt-1)
|
|
115
|
+
exponential = base_delay * (2**(attempt - 1))
|
|
116
|
+
# Cap at max
|
|
117
|
+
capped = [exponential, max_delay].min
|
|
118
|
+
# Add jitter: ±(delay * jitter_factor * random)
|
|
119
|
+
jitter = capped * jitter_factor * (rand - 0.5) * 2
|
|
120
|
+
capped + jitter
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LanguageOperator
|
|
4
|
+
# Mixin module to provide retry logic with exponential backoff for operations
|
|
5
|
+
# that may fail transiently.
|
|
6
|
+
#
|
|
7
|
+
# @example Basic usage
|
|
8
|
+
# class MyService
|
|
9
|
+
# include LanguageOperator::Retryable
|
|
10
|
+
#
|
|
11
|
+
# def connect
|
|
12
|
+
# with_retry do
|
|
13
|
+
# # Connection logic that might fail
|
|
14
|
+
# make_connection
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example Custom retry configuration
|
|
20
|
+
# with_retry(max_attempts: 5, base_delay: 2.0) do
|
|
21
|
+
# risky_operation
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example With custom error handling
|
|
25
|
+
# with_retry(on_retry: ->(error, attempt) { log_error(error, attempt) }) do
|
|
26
|
+
# api_call
|
|
27
|
+
# end
|
|
28
|
+
module Retryable
|
|
29
|
+
# Default retry configuration
|
|
30
|
+
DEFAULT_MAX_ATTEMPTS = 3
|
|
31
|
+
DEFAULT_BASE_DELAY = 1.0
|
|
32
|
+
DEFAULT_MAX_DELAY = 30.0
|
|
33
|
+
|
|
34
|
+
# Execute a block with retry logic and exponential backoff.
|
|
35
|
+
#
|
|
36
|
+
# @param max_attempts [Integer] Maximum number of attempts (default: 3)
|
|
37
|
+
# @param base_delay [Float] Base delay in seconds for exponential backoff (default: 1.0)
|
|
38
|
+
# @param max_delay [Float] Maximum delay between retries in seconds (default: 30.0)
|
|
39
|
+
# @param rescue_errors [Array<Class>] Array of error classes to rescue (default: StandardError)
|
|
40
|
+
# @param on_retry [Proc] Optional callback called on each retry attempt with (error, attempt, delay)
|
|
41
|
+
# @yield Block to execute with retry logic
|
|
42
|
+
# @return Result of the block if successful
|
|
43
|
+
# @raise Last error encountered if all retries are exhausted
|
|
44
|
+
#
|
|
45
|
+
# @example
|
|
46
|
+
# result = with_retry(max_attempts: 5) do
|
|
47
|
+
# fetch_from_api
|
|
48
|
+
# end
|
|
49
|
+
def with_retry(
|
|
50
|
+
max_attempts: DEFAULT_MAX_ATTEMPTS,
|
|
51
|
+
base_delay: DEFAULT_BASE_DELAY,
|
|
52
|
+
max_delay: DEFAULT_MAX_DELAY,
|
|
53
|
+
rescue_errors: [StandardError],
|
|
54
|
+
on_retry: nil
|
|
55
|
+
)
|
|
56
|
+
attempt = 0
|
|
57
|
+
last_error = nil
|
|
58
|
+
|
|
59
|
+
loop do
|
|
60
|
+
attempt += 1
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
return yield
|
|
64
|
+
rescue *rescue_errors => e
|
|
65
|
+
last_error = e
|
|
66
|
+
|
|
67
|
+
raise e if attempt >= max_attempts
|
|
68
|
+
|
|
69
|
+
# Calculate delay with exponential backoff: base_delay * 2^(attempt-1)
|
|
70
|
+
delay = [base_delay * (2**(attempt - 1)), max_delay].min
|
|
71
|
+
|
|
72
|
+
# Call the retry callback if provided
|
|
73
|
+
on_retry&.call(e, attempt, delay)
|
|
74
|
+
|
|
75
|
+
sleep(delay)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Execute a block with retry logic and return nil on failure instead of raising.
|
|
81
|
+
#
|
|
82
|
+
# @param max_attempts [Integer] Maximum number of attempts (default: 3)
|
|
83
|
+
# @param base_delay [Float] Base delay in seconds for exponential backoff (default: 1.0)
|
|
84
|
+
# @param max_delay [Float] Maximum delay between retries in seconds (default: 30.0)
|
|
85
|
+
# @param rescue_errors [Array<Class>] Array of error classes to rescue (default: StandardError)
|
|
86
|
+
# @param on_retry [Proc] Optional callback called on each retry attempt with (error, attempt, delay)
|
|
87
|
+
# @param on_failure [Proc] Optional callback called when all retries are exhausted with (error, attempts)
|
|
88
|
+
# @yield Block to execute with retry logic
|
|
89
|
+
# @return Result of the block if successful, nil if all retries failed
|
|
90
|
+
#
|
|
91
|
+
# @example
|
|
92
|
+
# result = with_retry_or_nil(on_failure: ->(err, tries) { log_failure(err) }) do
|
|
93
|
+
# optional_operation
|
|
94
|
+
# end
|
|
95
|
+
#
|
|
96
|
+
# return unless result
|
|
97
|
+
def with_retry_or_nil(
|
|
98
|
+
max_attempts: DEFAULT_MAX_ATTEMPTS,
|
|
99
|
+
base_delay: DEFAULT_BASE_DELAY,
|
|
100
|
+
max_delay: DEFAULT_MAX_DELAY,
|
|
101
|
+
rescue_errors: [StandardError],
|
|
102
|
+
on_retry: nil,
|
|
103
|
+
on_failure: nil
|
|
104
|
+
)
|
|
105
|
+
attempt = 0
|
|
106
|
+
last_error = nil
|
|
107
|
+
|
|
108
|
+
loop do
|
|
109
|
+
attempt += 1
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
return yield
|
|
113
|
+
rescue *rescue_errors => e
|
|
114
|
+
last_error = e
|
|
115
|
+
|
|
116
|
+
if attempt >= max_attempts
|
|
117
|
+
on_failure&.call(e, attempt)
|
|
118
|
+
return nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Calculate delay with exponential backoff
|
|
122
|
+
delay = [base_delay * (2**(attempt - 1)), max_delay].min
|
|
123
|
+
|
|
124
|
+
# Call the retry callback if provided
|
|
125
|
+
on_retry&.call(e, attempt, delay)
|
|
126
|
+
|
|
127
|
+
sleep(delay)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'dsl'
|
|
4
|
+
require 'mcp'
|
|
5
|
+
require 'opentelemetry/sdk'
|
|
6
|
+
|
|
7
|
+
module LanguageOperator
|
|
8
|
+
# Loads tool definitions from Ruby files
|
|
9
|
+
#
|
|
10
|
+
# Scans a directory for Ruby files containing tool definitions and loads them
|
|
11
|
+
# into a registry. Provides hot-reloading capability for development.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# registry = LanguageOperator::Dsl::Registry.new
|
|
15
|
+
# loader = LanguageOperator::ToolLoader.new(registry, '/mcp/tools')
|
|
16
|
+
# loader.load_tools
|
|
17
|
+
# puts "Loaded #{registry.all.length} tools"
|
|
18
|
+
#
|
|
19
|
+
# @example With custom context
|
|
20
|
+
# loader = LanguageOperator::ToolLoader.new(registry)
|
|
21
|
+
# loader.load_tools
|
|
22
|
+
# loader.reload # Hot reload tools
|
|
23
|
+
#
|
|
24
|
+
# @example Start MCP server
|
|
25
|
+
# LanguageOperator::ToolLoader.start # Loads tools and starts MCP server
|
|
26
|
+
class ToolLoader
|
|
27
|
+
# Initialize tool loader
|
|
28
|
+
#
|
|
29
|
+
# @param registry [LanguageOperator::Dsl::Registry] Tool registry
|
|
30
|
+
# @param tools_dir [String] Directory containing tool definition files
|
|
31
|
+
def initialize(registry, tools_dir = '/mcp')
|
|
32
|
+
@registry = registry
|
|
33
|
+
@tools_dir = tools_dir
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Load all tool files from the tools directory
|
|
37
|
+
#
|
|
38
|
+
# @return [void]
|
|
39
|
+
def load_tools
|
|
40
|
+
@registry.clear
|
|
41
|
+
|
|
42
|
+
unless Dir.exist?(@tools_dir)
|
|
43
|
+
puts "Tools directory #{@tools_dir} does not exist. Skipping tool loading."
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
tool_files = Dir.glob(File.join(@tools_dir, '**', '*.rb'))
|
|
48
|
+
|
|
49
|
+
if tool_files.empty?
|
|
50
|
+
puts "No tool files found in #{@tools_dir}"
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
tool_files.each do |file|
|
|
55
|
+
load_tool_file(file)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
puts "Loaded #{@registry.all.length} tools from #{tool_files.length} files"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Load a single tool file
|
|
62
|
+
#
|
|
63
|
+
# @param file [String] Path to tool definition file
|
|
64
|
+
# @return [void]
|
|
65
|
+
def load_tool_file(file)
|
|
66
|
+
puts "Loading tools from: #{file}"
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
context = LanguageOperator::Dsl::Context.new(@registry)
|
|
70
|
+
code = File.read(file)
|
|
71
|
+
|
|
72
|
+
# Execute in sandbox with validation
|
|
73
|
+
executor = LanguageOperator::Agent::Safety::SafeExecutor.new(context)
|
|
74
|
+
executor.eval(code, file)
|
|
75
|
+
rescue Agent::Safety::SafeExecutor::SecurityError, Agent::Safety::ASTValidator::SecurityError => e
|
|
76
|
+
# Re-raise security errors so they're not silently ignored
|
|
77
|
+
warn "Error loading tool file #{file}: #{e.message}"
|
|
78
|
+
warn e.backtrace.join("\n")
|
|
79
|
+
raise e
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
warn "Error loading tool file #{file}: #{e.message}"
|
|
82
|
+
warn e.backtrace.join("\n")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Reload all tools (hot reload)
|
|
87
|
+
#
|
|
88
|
+
# @return [void]
|
|
89
|
+
def reload
|
|
90
|
+
puts 'Reloading tools...'
|
|
91
|
+
load_tools
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Start an MCP server with loaded tools
|
|
95
|
+
#
|
|
96
|
+
# This class method creates a registry, loads tools from /mcp directory,
|
|
97
|
+
# wraps them as MCP::Tool classes, and starts an MCP server.
|
|
98
|
+
#
|
|
99
|
+
# Transport mode is automatically detected:
|
|
100
|
+
# - If PORT environment variable is set: HTTP server mode (for Kubernetes)
|
|
101
|
+
# - Otherwise: stdio transport mode (for local development)
|
|
102
|
+
#
|
|
103
|
+
# @param tools_dir [String] Directory containing tool definition files (default: '/mcp')
|
|
104
|
+
# @param server_name [String] Name of the MCP server (default: 'language-operator-tool')
|
|
105
|
+
# @return [void]
|
|
106
|
+
def self.start(tools_dir: '/mcp', server_name: 'language-operator-tool')
|
|
107
|
+
# Create registry and load tools
|
|
108
|
+
registry = LanguageOperator::Dsl::Registry.new
|
|
109
|
+
loader = new(registry, tools_dir)
|
|
110
|
+
loader.load_tools
|
|
111
|
+
|
|
112
|
+
# Convert DSL tools to MCP::Tool classes
|
|
113
|
+
mcp_tools = registry.all.map do |tool_def|
|
|
114
|
+
create_mcp_tool(tool_def)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Create MCP server
|
|
118
|
+
server = MCP::Server.new(
|
|
119
|
+
name: server_name,
|
|
120
|
+
tools: mcp_tools
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Auto-detect transport mode based on PORT environment variable
|
|
124
|
+
if ENV['PORT']
|
|
125
|
+
start_http_server(server, mcp_tools.length, ENV['PORT'].to_i)
|
|
126
|
+
else
|
|
127
|
+
start_stdio_server(server, mcp_tools.length)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Start MCP server in HTTP mode
|
|
132
|
+
#
|
|
133
|
+
# @param server [MCP::Server] The MCP server instance
|
|
134
|
+
# @param tool_count [Integer] Number of tools loaded
|
|
135
|
+
# @param port [Integer] Port to bind to
|
|
136
|
+
# @return [void]
|
|
137
|
+
def self.start_http_server(server, tool_count, port)
|
|
138
|
+
require 'rack'
|
|
139
|
+
require 'rackup'
|
|
140
|
+
|
|
141
|
+
# Create the Streamable HTTP transport
|
|
142
|
+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
|
|
143
|
+
server.transport = transport
|
|
144
|
+
|
|
145
|
+
# Create the Rack application
|
|
146
|
+
app = proc do |env|
|
|
147
|
+
request = Rack::Request.new(env)
|
|
148
|
+
transport.handle_request(request)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Build the Rack application with middleware
|
|
152
|
+
rack_app = Rack::Builder.new do
|
|
153
|
+
use Rack::CommonLogger
|
|
154
|
+
use Rack::ShowExceptions
|
|
155
|
+
run app
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
puts "Starting MCP HTTP server on http://0.0.0.0:#{port}"
|
|
159
|
+
puts "Loaded #{tool_count} tools"
|
|
160
|
+
|
|
161
|
+
# Start the server with Puma
|
|
162
|
+
Rackup::Handler.get('puma').run(rack_app, Port: port, Host: '0.0.0.0')
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Start MCP server in stdio mode
|
|
166
|
+
#
|
|
167
|
+
# @param server [MCP::Server] The MCP server instance
|
|
168
|
+
# @param tool_count [Integer] Number of tools loaded
|
|
169
|
+
# @return [void]
|
|
170
|
+
def self.start_stdio_server(server, tool_count)
|
|
171
|
+
# Use stdio transport
|
|
172
|
+
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
173
|
+
puts "Starting MCP server with #{tool_count} tools (stdio mode)"
|
|
174
|
+
transport.open
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Convert a DSL tool definition to an MCP::Tool class
|
|
178
|
+
#
|
|
179
|
+
# @param tool_def [LanguageOperator::Dsl::ToolDefinition] Tool definition from DSL
|
|
180
|
+
# @return [Class] MCP::Tool subclass
|
|
181
|
+
# rubocop:disable Metrics/MethodLength
|
|
182
|
+
def self.create_mcp_tool(tool_def)
|
|
183
|
+
# Capture tool name and tracer for use in the dynamic class
|
|
184
|
+
tool_name = tool_def.name
|
|
185
|
+
tracer = OpenTelemetry.tracer_provider.tracer('language-operator-agent', LanguageOperator::VERSION)
|
|
186
|
+
|
|
187
|
+
# Create a dynamic MCP::Tool class
|
|
188
|
+
Class.new(MCP::Tool) do
|
|
189
|
+
description tool_def.description || "Tool: #{tool_def.name}"
|
|
190
|
+
|
|
191
|
+
# Build input schema from parameters
|
|
192
|
+
properties = {}
|
|
193
|
+
required_params = []
|
|
194
|
+
|
|
195
|
+
tool_def.parameters.each do |param_name, param_def|
|
|
196
|
+
properties[param_name] = {
|
|
197
|
+
type: param_def.type&.to_s || 'string',
|
|
198
|
+
description: param_def.description
|
|
199
|
+
}
|
|
200
|
+
required_params << param_name if param_def.required?
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
input_schema(
|
|
204
|
+
properties: properties,
|
|
205
|
+
required: required_params
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Store the execute block
|
|
209
|
+
@execute_block = tool_def.execute_block
|
|
210
|
+
|
|
211
|
+
# Define the call method with OpenTelemetry instrumentation
|
|
212
|
+
define_singleton_method(:call) do |**params|
|
|
213
|
+
tracer.in_span('agent.tool.execute', attributes: {
|
|
214
|
+
'tool.name' => tool_name,
|
|
215
|
+
'tool.type' => 'custom'
|
|
216
|
+
}) do |span|
|
|
217
|
+
# Execute the tool's block
|
|
218
|
+
result = @execute_block.call(params)
|
|
219
|
+
|
|
220
|
+
# Set success attribute
|
|
221
|
+
span.set_attribute('tool.result', 'success')
|
|
222
|
+
|
|
223
|
+
# Return MCP response
|
|
224
|
+
MCP::Tool::Response.new([
|
|
225
|
+
{
|
|
226
|
+
type: 'text',
|
|
227
|
+
text: result.to_s
|
|
228
|
+
}
|
|
229
|
+
])
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
# Record exception and set failure status
|
|
232
|
+
span.record_exception(e)
|
|
233
|
+
span.set_attribute('tool.result', 'failure')
|
|
234
|
+
span.status = OpenTelemetry::Trace::Status.error(e.message)
|
|
235
|
+
raise
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
# rubocop:enable Metrics/MethodLength
|
|
241
|
+
end
|
|
242
|
+
end
|