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,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
|