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,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'budget_tracker'
4
+ require_relative 'rate_limiter'
5
+ require_relative 'content_filter'
6
+ require_relative 'audit_logger'
7
+ require_relative '../../logger'
8
+ require_relative '../../loggable'
9
+
10
+ module LanguageOperator
11
+ module Agent
12
+ module Safety
13
+ # Safety Manager
14
+ #
15
+ # Coordinates all safety components (budget tracking, rate limiting,
16
+ # content filtering, audit logging) and provides a unified interface.
17
+ #
18
+ # @example
19
+ # safety = Manager.new(
20
+ # daily_budget: 10.0,
21
+ # requests_per_minute: 10,
22
+ # blocked_patterns: ['password']
23
+ # )
24
+ # safety.check_request!(message: "Hello", estimated_cost: 0.01)
25
+ class Manager
26
+ include LanguageOperator::Loggable
27
+
28
+ attr_reader :budget_tracker, :rate_limiter, :content_filter, :audit_logger
29
+
30
+ def initialize(config = {})
31
+ @config = config
32
+ @enabled = config.fetch(:enabled, true)
33
+
34
+ # Initialize components based on configuration
35
+ @budget_tracker = if budget_config_present?
36
+ BudgetTracker.new(
37
+ daily_budget: config[:daily_budget],
38
+ hourly_budget: config[:hourly_budget],
39
+ token_budget: config[:token_budget]
40
+ )
41
+ end
42
+
43
+ @rate_limiter = if rate_limit_config_present?
44
+ RateLimiter.new(
45
+ requests_per_minute: config[:requests_per_minute],
46
+ requests_per_hour: config[:requests_per_hour],
47
+ requests_per_day: config[:requests_per_day]
48
+ )
49
+ end
50
+
51
+ @content_filter = if content_filter_config_present?
52
+ ContentFilter.new(
53
+ blocked_patterns: config[:blocked_patterns] || [],
54
+ blocked_topics: config[:blocked_topics] || [],
55
+ case_sensitive: config[:case_sensitive] || false
56
+ )
57
+ end
58
+
59
+ @audit_logger = (AuditLogger.new if config.fetch(:audit_logging, true))
60
+
61
+ logger.info('Safety manager initialized',
62
+ enabled: @enabled,
63
+ budget_tracking: !@budget_tracker.nil?,
64
+ rate_limiting: !@rate_limiter.nil?,
65
+ content_filtering: !@content_filter.nil?,
66
+ audit_logging: !@audit_logger.nil?)
67
+ end
68
+
69
+ # Check if safety checks are enabled
70
+ #
71
+ # @return [Boolean]
72
+ def enabled?
73
+ @enabled
74
+ end
75
+
76
+ # Perform all pre-request safety checks
77
+ #
78
+ # @param message [String] The message being sent
79
+ # @param estimated_cost [Float] Estimated cost of the request
80
+ # @param estimated_tokens [Integer] Estimated token count
81
+ # @raise [BudgetTracker::BudgetExceededError] If budget exceeded
82
+ # @raise [RateLimiter::RateLimitExceededError] If rate limit exceeded
83
+ # @raise [ContentFilter::ContentBlockedError] If content blocked
84
+ def check_request!(message:, estimated_cost: 0.0, estimated_tokens: 0)
85
+ return unless @enabled
86
+
87
+ begin
88
+ # Check content filter (input)
89
+ if @content_filter
90
+ @content_filter.check_content!(message, direction: :input)
91
+ logger.debug('Input content check passed')
92
+ end
93
+
94
+ # Check budget
95
+ if @budget_tracker
96
+ @budget_tracker.check_budget!(
97
+ estimated_cost: estimated_cost,
98
+ estimated_tokens: estimated_tokens
99
+ )
100
+ logger.debug('Budget check passed')
101
+ end
102
+
103
+ # Check rate limit
104
+ if @rate_limiter
105
+ @rate_limiter.check_rate_limit!
106
+ logger.debug('Rate limit check passed')
107
+ end
108
+
109
+ logger.debug('All safety checks passed')
110
+ rescue BudgetTracker::BudgetExceededError,
111
+ RateLimiter::RateLimitExceededError,
112
+ ContentFilter::ContentBlockedError => e
113
+ # Log to audit
114
+ @audit_logger&.log_blocked_request(
115
+ reason: e.class.name,
116
+ details: {
117
+ message: e.message,
118
+ message_preview: message[0..100],
119
+ estimated_cost: estimated_cost,
120
+ estimated_tokens: estimated_tokens
121
+ }
122
+ )
123
+ raise
124
+ end
125
+ end
126
+
127
+ # Check response content
128
+ #
129
+ # @param response [String] The LLM response
130
+ # @raise [ContentFilter::ContentBlockedError] If content blocked
131
+ def check_response!(response)
132
+ return unless @enabled
133
+ return unless @content_filter
134
+
135
+ begin
136
+ @content_filter.check_content!(response, direction: :output)
137
+ logger.debug('Output content check passed')
138
+ rescue ContentFilter::ContentBlockedError => e
139
+ @audit_logger&.log_content_filter_event(
140
+ event: 'output_blocked',
141
+ details: {
142
+ message: e.message,
143
+ response_preview: response[0..100]
144
+ }
145
+ )
146
+ raise
147
+ end
148
+ end
149
+
150
+ # Record successful request
151
+ #
152
+ # @param cost [Float] Actual cost
153
+ # @param tokens [Integer] Actual token count
154
+ def record_request(cost:, tokens:)
155
+ return unless @enabled
156
+
157
+ @budget_tracker&.record_spending(cost: cost, tokens: tokens)
158
+ @rate_limiter&.record_request
159
+
160
+ @audit_logger&.log_budget_event(
161
+ event: 'request_completed',
162
+ details: {
163
+ cost: cost.round(4),
164
+ tokens: tokens,
165
+ budget_status: @budget_tracker&.status
166
+ }
167
+ )
168
+ end
169
+
170
+ # Get current safety status
171
+ #
172
+ # @return [Hash] Safety status information
173
+ def status
174
+ {
175
+ enabled: @enabled,
176
+ budget: @budget_tracker&.status,
177
+ rate_limits: @rate_limiter&.status,
178
+ content_filter: {
179
+ enabled: !@content_filter.nil?,
180
+ patterns: @config[:blocked_patterns]&.length || 0,
181
+ topics: @config[:blocked_topics]&.length || 0
182
+ }
183
+ }
184
+ end
185
+
186
+ private
187
+
188
+ def logger_component
189
+ 'Safety::Manager'
190
+ end
191
+
192
+ def budget_config_present?
193
+ @config[:daily_budget] || @config[:hourly_budget] || @config[:token_budget]
194
+ end
195
+
196
+ def rate_limit_config_present?
197
+ @config[:requests_per_minute] || @config[:requests_per_hour] || @config[:requests_per_day]
198
+ end
199
+
200
+ def content_filter_config_present?
201
+ @config[:blocked_patterns]&.any? ||
202
+ @config[:blocked_topics]&.any?
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../logger'
4
+ require_relative '../../loggable'
5
+
6
+ module LanguageOperator
7
+ module Agent
8
+ module Safety
9
+ # Rate Limiter for enforcing request limits
10
+ #
11
+ # Enforces per-agent rate limiting to prevent runaway requests.
12
+ #
13
+ # @example
14
+ # limiter = RateLimiter.new(requests_per_minute: 10, requests_per_hour: 100)
15
+ # limiter.check_rate_limit!
16
+ class RateLimiter
17
+ include LanguageOperator::Loggable
18
+
19
+ class RateLimitExceededError < StandardError; end
20
+
21
+ def initialize(requests_per_minute: nil, requests_per_hour: nil, requests_per_day: nil)
22
+ @requests_per_minute = requests_per_minute&.to_i
23
+ @requests_per_hour = requests_per_hour&.to_i
24
+ @requests_per_day = requests_per_day&.to_i
25
+
26
+ @minute_requests = []
27
+ @hour_requests = []
28
+ @day_requests = []
29
+
30
+ logger.info('Rate limiter initialized',
31
+ per_minute: @requests_per_minute,
32
+ per_hour: @requests_per_hour,
33
+ per_day: @requests_per_day)
34
+ end
35
+
36
+ # Check if a request would exceed rate limits
37
+ #
38
+ # @raise [RateLimitExceededError] If rate limit would be exceeded
39
+ def check_rate_limit!
40
+ now = Time.now
41
+
42
+ # Clean up old requests
43
+ cleanup_old_requests(now)
44
+
45
+ # Check per-minute limit
46
+ if @requests_per_minute && @minute_requests.length >= @requests_per_minute
47
+ oldest = @minute_requests.first
48
+ wait_time = 60 - (now - oldest)
49
+ logger.error('Per-minute rate limit exceeded',
50
+ current: @minute_requests.length,
51
+ limit: @requests_per_minute,
52
+ wait_time: wait_time.round(1))
53
+ raise RateLimitExceededError,
54
+ "Per-minute rate limit exceeded: #{@minute_requests.length}/#{@requests_per_minute} " \
55
+ "(retry in #{wait_time.round(1)}s)"
56
+ end
57
+
58
+ # Check per-hour limit
59
+ if @requests_per_hour && @hour_requests.length >= @requests_per_hour
60
+ oldest = @hour_requests.first
61
+ wait_time = 3600 - (now - oldest)
62
+ logger.error('Per-hour rate limit exceeded',
63
+ current: @hour_requests.length,
64
+ limit: @requests_per_hour,
65
+ wait_time: (wait_time / 60).round(1))
66
+ raise RateLimitExceededError,
67
+ "Per-hour rate limit exceeded: #{@hour_requests.length}/#{@requests_per_hour} " \
68
+ "(retry in #{(wait_time / 60).round(1)}m)"
69
+ end
70
+
71
+ # Check per-day limit
72
+ if @requests_per_day && @day_requests.length >= @requests_per_day
73
+ oldest = @day_requests.first
74
+ wait_time = 86_400 - (now - oldest)
75
+ logger.error('Per-day rate limit exceeded',
76
+ current: @day_requests.length,
77
+ limit: @requests_per_day,
78
+ wait_time: (wait_time / 3600).round(1))
79
+ raise RateLimitExceededError,
80
+ "Per-day rate limit exceeded: #{@day_requests.length}/#{@requests_per_day} " \
81
+ "(retry in #{(wait_time / 3600).round(1)}h)"
82
+ end
83
+
84
+ logger.debug('Rate limit check passed',
85
+ minute_requests: @minute_requests.length,
86
+ hour_requests: @hour_requests.length,
87
+ day_requests: @day_requests.length)
88
+ end
89
+
90
+ # Record a request
91
+ def record_request
92
+ now = Time.now
93
+ cleanup_old_requests(now)
94
+
95
+ @minute_requests << now
96
+ @hour_requests << now
97
+ @day_requests << now
98
+
99
+ logger.debug('Request recorded',
100
+ minute_count: @minute_requests.length,
101
+ hour_count: @hour_requests.length,
102
+ day_count: @day_requests.length)
103
+ end
104
+
105
+ # Get current rate limit status
106
+ #
107
+ # @return [Hash] Rate limit status information
108
+ def status
109
+ now = Time.now
110
+ cleanup_old_requests(now)
111
+
112
+ {
113
+ per_minute: {
114
+ requests: @minute_requests.length,
115
+ limit: @requests_per_minute,
116
+ remaining: @requests_per_minute ? (@requests_per_minute - @minute_requests.length) : nil
117
+ },
118
+ per_hour: {
119
+ requests: @hour_requests.length,
120
+ limit: @requests_per_hour,
121
+ remaining: @requests_per_hour ? (@requests_per_hour - @hour_requests.length) : nil
122
+ },
123
+ per_day: {
124
+ requests: @day_requests.length,
125
+ limit: @requests_per_day,
126
+ remaining: @requests_per_day ? (@requests_per_day - @day_requests.length) : nil
127
+ }
128
+ }
129
+ end
130
+
131
+ private
132
+
133
+ def logger_component
134
+ 'Safety::RateLimiter'
135
+ end
136
+
137
+ def cleanup_old_requests(now)
138
+ # Remove requests older than 1 minute
139
+ @minute_requests.reject! { |t| (now - t) > 60 }
140
+
141
+ # Remove requests older than 1 hour
142
+ @hour_requests.reject! { |t| (now - t) > 3600 }
143
+
144
+ # Remove requests older than 1 day
145
+ @day_requests.reject! { |t| (now - t) > 86_400 }
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LanguageOperator
4
+ module Agent
5
+ module Safety
6
+ # Executes Ruby code in a sandboxed context with method whitelisting
7
+ # Wraps the execution context to prevent dangerous method calls at runtime
8
+ class SafeExecutor
9
+ class SecurityError < StandardError; end
10
+
11
+ # Methods that are always safe to call
12
+ ALWAYS_SAFE_METHODS = %i[
13
+ nil? == != eql? equal? hash object_id class is_a? kind_of? instance_of?
14
+ respond_to? send_to? methods public_methods private_methods
15
+ instance_variables instance_variable_get instance_variable_set
16
+ to_s to_str inspect to_a to_h to_i to_f to_sym
17
+ freeze frozen? dup clone
18
+ ].freeze
19
+
20
+ def initialize(context, validator: nil)
21
+ @context = context
22
+ @validator = validator || ASTValidator.new
23
+ @audit_log = []
24
+ end
25
+
26
+ # Execute code in the sandboxed context
27
+ # @param code [String] Ruby code to execute
28
+ # @param file_path [String] Path to file (for error reporting)
29
+ # @return [Object] Result of code execution
30
+ def eval(code, file_path = '(eval)')
31
+ # Step 1: Validate code with AST analysis
32
+ @validator.validate!(code, file_path)
33
+
34
+ # Step 2: Execute in sandboxed context
35
+ sandbox = SandboxProxy.new(@context, self)
36
+
37
+ # Step 3: Execute using instance_eval
38
+ # Note: We still use instance_eval but with validated code
39
+ # and wrapped context
40
+ sandbox.instance_eval(code, file_path)
41
+ rescue ASTValidator::SecurityError => e
42
+ # Re-raise validation errors as executor errors for clarity
43
+ raise SecurityError, "Code validation failed: #{e.message}"
44
+ end
45
+
46
+ # Log method calls for auditing
47
+ def log_call(receiver, method_name, args)
48
+ @audit_log << {
49
+ timestamp: Time.now,
50
+ receiver: receiver.class.name,
51
+ method: method_name,
52
+ args: args.map(&:class).map(&:name)
53
+ }
54
+ end
55
+
56
+ # Get audit log
57
+ attr_reader :audit_log
58
+
59
+ # Proxy class that wraps the context and intercepts method calls
60
+ class SandboxProxy < BasicObject
61
+ def initialize(context, executor)
62
+ @__context__ = context
63
+ @__executor__ = executor
64
+ end
65
+
66
+ # Delegate method calls to the underlying context
67
+ # but check for dangerous methods first
68
+ def method_missing(method_name, *args, &)
69
+ # Log the call
70
+ @__executor__.log_call(@__context__, method_name, args)
71
+
72
+ # Check if method is safe
73
+ unless safe_method?(method_name)
74
+ ::Kernel.raise ::LanguageOperator::Agent::Safety::SafeExecutor::SecurityError,
75
+ "Method '#{method_name}' is not allowed in sandboxed code"
76
+ end
77
+
78
+ # Delegate to underlying context
79
+ @__context__.send(method_name, *args, &)
80
+ end
81
+
82
+ def respond_to_missing?(method_name, include_private = false)
83
+ @__context__.respond_to?(method_name, include_private)
84
+ end
85
+
86
+ # Provide access to safe constants from the context
87
+ def const_missing(name)
88
+ # Allow access to HTTP and Shell helper classes
89
+ if name == :HTTP
90
+ return ::LanguageOperator::Dsl::HTTP
91
+ elsif name == :Shell
92
+ return ::LanguageOperator::Dsl::Shell
93
+ end
94
+
95
+ # Otherwise delegate to the context's module
96
+ @__context__.class.const_get(name)
97
+ rescue ::NameError
98
+ ::Kernel.raise ::NameError, "uninitialized constant #{name}"
99
+ end
100
+
101
+ private
102
+
103
+ def safe_method?(method_name)
104
+ # Always allow safe basic methods
105
+ return true if ::LanguageOperator::Agent::Safety::SafeExecutor::ALWAYS_SAFE_METHODS.include?(method_name)
106
+
107
+ # Check if the underlying context responds to the method
108
+ # (This allows DSL methods defined on the context)
109
+ @__context__.respond_to?(method_name)
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rufus-scheduler'
4
+ require_relative 'executor'
5
+ require_relative 'instrumentation'
6
+ require_relative '../logger'
7
+ require_relative '../loggable'
8
+
9
+ module LanguageOperator
10
+ module Agent
11
+ # Task Scheduler
12
+ #
13
+ # Handles scheduled and event-driven task execution using rufus-scheduler.
14
+ #
15
+ # @example
16
+ # scheduler = Scheduler.new(agent)
17
+ # scheduler.start
18
+ class Scheduler
19
+ include LanguageOperator::Loggable
20
+ include LanguageOperator::Agent::Instrumentation
21
+
22
+ attr_reader :agent, :rufus_scheduler
23
+
24
+ # Initialize the scheduler
25
+ #
26
+ # @param agent [LanguageOperator::Agent::Base] The agent instance
27
+ def initialize(agent)
28
+ @agent = agent
29
+ @rufus_scheduler = Rufus::Scheduler.new
30
+ @executor = Executor.new(agent)
31
+
32
+ logger.debug('Scheduler initialized',
33
+ workspace: @agent.workspace_path,
34
+ servers: @agent.servers_info.length)
35
+ end
36
+
37
+ # Start the scheduler
38
+ #
39
+ # @return [void]
40
+ def start
41
+ logger.info('Agent starting in scheduled mode')
42
+ logger.info("Workspace: #{@agent.workspace_path}")
43
+ logger.info("Connected to #{@agent.servers_info.length} MCP server(s)")
44
+
45
+ setup_schedules
46
+ logger.info('Scheduler started, waiting for scheduled tasks')
47
+ @rufus_scheduler.join
48
+ end
49
+
50
+ # Start the scheduler with a workflow definition
51
+ #
52
+ # @param agent_def [LanguageOperator::Dsl::AgentDefinition] The agent definition with workflow
53
+ # @return [void]
54
+ def start_with_workflow(agent_def)
55
+ logger.info('Agent starting in scheduled mode with workflow',
56
+ agent_name: agent_def.name,
57
+ has_workflow: !agent_def.workflow.nil?)
58
+ logger.info("Workspace: #{@agent.workspace_path}")
59
+ logger.info("Connected to #{@agent.servers_info.length} MCP server(s)")
60
+
61
+ # Extract schedule from agent definition or use default
62
+ cron_schedule = agent_def.schedule&.cron || '0 6 * * *'
63
+
64
+ logger.info('Scheduling workflow', cron: cron_schedule, agent: agent_def.name)
65
+
66
+ @rufus_scheduler.cron(cron_schedule) do
67
+ with_span('agent.scheduler.execute', attributes: {
68
+ 'scheduler.cron_expression' => cron_schedule,
69
+ 'agent.name' => agent_def.name,
70
+ 'scheduler.task_type' => 'workflow'
71
+ }) do
72
+ logger.timed('Scheduled workflow execution') do
73
+ logger.info('Executing scheduled workflow', agent: agent_def.name)
74
+ result = @executor.execute_workflow(agent_def)
75
+ result_text = result.is_a?(String) ? result : result.content
76
+ preview = result_text[0..200]
77
+ preview += '...' if result_text.length > 200
78
+ logger.info('Workflow completed', result_preview: preview)
79
+ end
80
+ end
81
+ end
82
+
83
+ logger.info('Scheduler started, waiting for scheduled tasks')
84
+ @rufus_scheduler.join
85
+ end
86
+
87
+ # Stop the scheduler
88
+ #
89
+ # @return [void]
90
+ def stop
91
+ logger.info('Shutting down scheduler')
92
+ @rufus_scheduler.shutdown
93
+ logger.info('Scheduler stopped')
94
+ end
95
+
96
+ private
97
+
98
+ def logger_component
99
+ 'Agent::Scheduler'
100
+ end
101
+
102
+ # Setup schedules from config
103
+ #
104
+ # @return [void]
105
+ def setup_schedules
106
+ schedules = @agent.config.dig('agent', 'schedules') || []
107
+
108
+ logger.debug('Loading schedules from config', count: schedules.length)
109
+
110
+ if schedules.empty?
111
+ logger.warn('No schedules configured, using default daily schedule')
112
+ setup_default_schedule
113
+ return
114
+ end
115
+
116
+ schedules.each do |schedule|
117
+ add_schedule(schedule)
118
+ end
119
+
120
+ logger.info("#{schedules.length} schedule(s) configured")
121
+ end
122
+
123
+ # Add a single schedule
124
+ #
125
+ # @param schedule [Hash] Schedule configuration
126
+ # @return [void]
127
+ def add_schedule(schedule)
128
+ cron = schedule['cron']
129
+ task = schedule['task']
130
+ agent_name = @agent.config.dig('agent', 'name')
131
+
132
+ logger.info('Scheduling task', cron: cron, task: task[0..100])
133
+
134
+ @rufus_scheduler.cron(cron) do
135
+ with_span('agent.scheduler.execute', attributes: {
136
+ 'scheduler.cron_expression' => cron,
137
+ 'agent.name' => agent_name,
138
+ 'scheduler.task_type' => 'scheduled'
139
+ }) do
140
+ logger.timed('Scheduled task execution') do
141
+ logger.info('Executing scheduled task', task: task[0..100])
142
+ result = @executor.execute(task)
143
+ preview = result[0..200]
144
+ preview += '...' if result.length > 200
145
+ logger.info('Task completed', result_preview: preview)
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+ # Setup default daily schedule
152
+ #
153
+ # @return [void]
154
+ def setup_default_schedule
155
+ instructions = @agent.config.dig('agent', 'instructions') ||
156
+ 'Check for updates and report status'
157
+ agent_name = @agent.config.dig('agent', 'name')
158
+ cron = '0 6 * * *'
159
+
160
+ logger.info('Setting up default schedule', cron: cron,
161
+ instructions: instructions[0..100])
162
+
163
+ @rufus_scheduler.cron(cron) do
164
+ with_span('agent.scheduler.execute', attributes: {
165
+ 'scheduler.cron_expression' => cron,
166
+ 'agent.name' => agent_name,
167
+ 'scheduler.task_type' => 'default'
168
+ }) do
169
+ logger.timed('Daily task execution') do
170
+ logger.info('Executing daily task')
171
+ result = @executor.execute(instructions)
172
+ preview = result[0..200]
173
+ preview += '...' if result.length > 200
174
+ logger.info('Daily task completed', result_preview: preview)
175
+ end
176
+ end
177
+ end
178
+
179
+ logger.info('Scheduled: Daily at 6:00 AM')
180
+ end
181
+ end
182
+ end
183
+ end