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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +125 -0
  3. data/CHANGELOG.md +53 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +284 -0
  6. data/LICENSE +229 -21
  7. data/Makefile +77 -0
  8. data/README.md +3 -11
  9. data/Rakefile +34 -0
  10. data/bin/aictl +7 -0
  11. data/completions/_aictl +232 -0
  12. data/completions/aictl.bash +121 -0
  13. data/completions/aictl.fish +114 -0
  14. data/docs/architecture/agent-runtime.md +585 -0
  15. data/docs/dsl/agent-reference.md +591 -0
  16. data/docs/dsl/best-practices.md +1078 -0
  17. data/docs/dsl/chat-endpoints.md +895 -0
  18. data/docs/dsl/constraints.md +671 -0
  19. data/docs/dsl/mcp-integration.md +1177 -0
  20. data/docs/dsl/webhooks.md +932 -0
  21. data/docs/dsl/workflows.md +744 -0
  22. data/examples/README.md +569 -0
  23. data/examples/agent_example.rb +86 -0
  24. data/examples/chat_endpoint_agent.rb +118 -0
  25. data/examples/github_webhook_agent.rb +171 -0
  26. data/examples/mcp_agent.rb +158 -0
  27. data/examples/oauth_callback_agent.rb +296 -0
  28. data/examples/stripe_webhook_agent.rb +219 -0
  29. data/examples/webhook_agent.rb +80 -0
  30. data/lib/language_operator/agent/base.rb +110 -0
  31. data/lib/language_operator/agent/executor.rb +440 -0
  32. data/lib/language_operator/agent/instrumentation.rb +54 -0
  33. data/lib/language_operator/agent/metrics_tracker.rb +183 -0
  34. data/lib/language_operator/agent/safety/ast_validator.rb +272 -0
  35. data/lib/language_operator/agent/safety/audit_logger.rb +104 -0
  36. data/lib/language_operator/agent/safety/budget_tracker.rb +175 -0
  37. data/lib/language_operator/agent/safety/content_filter.rb +93 -0
  38. data/lib/language_operator/agent/safety/manager.rb +207 -0
  39. data/lib/language_operator/agent/safety/rate_limiter.rb +150 -0
  40. data/lib/language_operator/agent/safety/safe_executor.rb +115 -0
  41. data/lib/language_operator/agent/scheduler.rb +183 -0
  42. data/lib/language_operator/agent/telemetry.rb +116 -0
  43. data/lib/language_operator/agent/web_server.rb +610 -0
  44. data/lib/language_operator/agent/webhook_authenticator.rb +226 -0
  45. data/lib/language_operator/agent.rb +149 -0
  46. data/lib/language_operator/cli/commands/agent.rb +1252 -0
  47. data/lib/language_operator/cli/commands/cluster.rb +335 -0
  48. data/lib/language_operator/cli/commands/install.rb +404 -0
  49. data/lib/language_operator/cli/commands/model.rb +266 -0
  50. data/lib/language_operator/cli/commands/persona.rb +396 -0
  51. data/lib/language_operator/cli/commands/quickstart.rb +22 -0
  52. data/lib/language_operator/cli/commands/status.rb +156 -0
  53. data/lib/language_operator/cli/commands/tool.rb +537 -0
  54. data/lib/language_operator/cli/commands/use.rb +47 -0
  55. data/lib/language_operator/cli/errors/handler.rb +180 -0
  56. data/lib/language_operator/cli/errors/suggestions.rb +176 -0
  57. data/lib/language_operator/cli/formatters/code_formatter.rb +81 -0
  58. data/lib/language_operator/cli/formatters/log_formatter.rb +290 -0
  59. data/lib/language_operator/cli/formatters/progress_formatter.rb +53 -0
  60. data/lib/language_operator/cli/formatters/table_formatter.rb +179 -0
  61. data/lib/language_operator/cli/formatters/value_formatter.rb +113 -0
  62. data/lib/language_operator/cli/helpers/cluster_context.rb +62 -0
  63. data/lib/language_operator/cli/helpers/cluster_validator.rb +101 -0
  64. data/lib/language_operator/cli/helpers/editor_helper.rb +58 -0
  65. data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +167 -0
  66. data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +74 -0
  67. data/lib/language_operator/cli/helpers/schedule_builder.rb +108 -0
  68. data/lib/language_operator/cli/helpers/user_prompts.rb +69 -0
  69. data/lib/language_operator/cli/main.rb +232 -0
  70. data/lib/language_operator/cli/templates/tools/generic.yaml +66 -0
  71. data/lib/language_operator/cli/wizards/agent_wizard.rb +246 -0
  72. data/lib/language_operator/cli/wizards/quickstart_wizard.rb +588 -0
  73. data/lib/language_operator/client/base.rb +214 -0
  74. data/lib/language_operator/client/config.rb +136 -0
  75. data/lib/language_operator/client/cost_calculator.rb +37 -0
  76. data/lib/language_operator/client/mcp_connector.rb +123 -0
  77. data/lib/language_operator/client.rb +19 -0
  78. data/lib/language_operator/config/cluster_config.rb +101 -0
  79. data/lib/language_operator/config/tool_patterns.yaml +57 -0
  80. data/lib/language_operator/config/tool_registry.rb +96 -0
  81. data/lib/language_operator/config.rb +138 -0
  82. data/lib/language_operator/dsl/adapter.rb +124 -0
  83. data/lib/language_operator/dsl/agent_context.rb +90 -0
  84. data/lib/language_operator/dsl/agent_definition.rb +427 -0
  85. data/lib/language_operator/dsl/chat_endpoint_definition.rb +115 -0
  86. data/lib/language_operator/dsl/config.rb +119 -0
  87. data/lib/language_operator/dsl/context.rb +50 -0
  88. data/lib/language_operator/dsl/execution_context.rb +47 -0
  89. data/lib/language_operator/dsl/helpers.rb +109 -0
  90. data/lib/language_operator/dsl/http.rb +184 -0
  91. data/lib/language_operator/dsl/mcp_server_definition.rb +73 -0
  92. data/lib/language_operator/dsl/parameter_definition.rb +124 -0
  93. data/lib/language_operator/dsl/registry.rb +36 -0
  94. data/lib/language_operator/dsl/shell.rb +125 -0
  95. data/lib/language_operator/dsl/tool_definition.rb +112 -0
  96. data/lib/language_operator/dsl/webhook_authentication.rb +114 -0
  97. data/lib/language_operator/dsl/webhook_definition.rb +106 -0
  98. data/lib/language_operator/dsl/workflow_definition.rb +259 -0
  99. data/lib/language_operator/dsl.rb +160 -0
  100. data/lib/language_operator/errors.rb +60 -0
  101. data/lib/language_operator/kubernetes/client.rb +279 -0
  102. data/lib/language_operator/kubernetes/resource_builder.rb +194 -0
  103. data/lib/language_operator/loggable.rb +47 -0
  104. data/lib/language_operator/logger.rb +141 -0
  105. data/lib/language_operator/retry.rb +123 -0
  106. data/lib/language_operator/retryable.rb +132 -0
  107. data/lib/language_operator/tool_loader.rb +242 -0
  108. data/lib/language_operator/validators.rb +170 -0
  109. data/lib/language_operator/version.rb +1 -1
  110. data/lib/language_operator.rb +65 -3
  111. data/requirements/tasks/challenge.md +9 -0
  112. data/requirements/tasks/iterate.md +36 -0
  113. data/requirements/tasks/optimize.md +21 -0
  114. data/requirements/tasks/tag.md +5 -0
  115. data/test_agent_dsl.rb +108 -0
  116. 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