language-operator 0.0.1 → 0.1.31

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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +125 -0
  3. data/CHANGELOG.md +88 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +284 -0
  6. data/LICENSE +229 -21
  7. data/Makefile +82 -0
  8. data/README.md +3 -11
  9. data/Rakefile +63 -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/SCHEMA_VERSION.md +250 -0
  16. data/docs/dsl/agent-reference.md +604 -0
  17. data/docs/dsl/best-practices.md +1078 -0
  18. data/docs/dsl/chat-endpoints.md +895 -0
  19. data/docs/dsl/constraints.md +671 -0
  20. data/docs/dsl/mcp-integration.md +1177 -0
  21. data/docs/dsl/webhooks.md +932 -0
  22. data/docs/dsl/workflows.md +744 -0
  23. data/lib/language_operator/agent/base.rb +110 -0
  24. data/lib/language_operator/agent/executor.rb +440 -0
  25. data/lib/language_operator/agent/instrumentation.rb +54 -0
  26. data/lib/language_operator/agent/metrics_tracker.rb +183 -0
  27. data/lib/language_operator/agent/safety/ast_validator.rb +272 -0
  28. data/lib/language_operator/agent/safety/audit_logger.rb +104 -0
  29. data/lib/language_operator/agent/safety/budget_tracker.rb +175 -0
  30. data/lib/language_operator/agent/safety/content_filter.rb +93 -0
  31. data/lib/language_operator/agent/safety/manager.rb +207 -0
  32. data/lib/language_operator/agent/safety/rate_limiter.rb +150 -0
  33. data/lib/language_operator/agent/safety/safe_executor.rb +127 -0
  34. data/lib/language_operator/agent/scheduler.rb +183 -0
  35. data/lib/language_operator/agent/telemetry.rb +116 -0
  36. data/lib/language_operator/agent/web_server.rb +610 -0
  37. data/lib/language_operator/agent/webhook_authenticator.rb +226 -0
  38. data/lib/language_operator/agent.rb +149 -0
  39. data/lib/language_operator/cli/commands/agent.rb +1205 -0
  40. data/lib/language_operator/cli/commands/cluster.rb +371 -0
  41. data/lib/language_operator/cli/commands/install.rb +404 -0
  42. data/lib/language_operator/cli/commands/model.rb +266 -0
  43. data/lib/language_operator/cli/commands/persona.rb +393 -0
  44. data/lib/language_operator/cli/commands/quickstart.rb +22 -0
  45. data/lib/language_operator/cli/commands/status.rb +143 -0
  46. data/lib/language_operator/cli/commands/system.rb +772 -0
  47. data/lib/language_operator/cli/commands/tool.rb +537 -0
  48. data/lib/language_operator/cli/commands/use.rb +47 -0
  49. data/lib/language_operator/cli/errors/handler.rb +180 -0
  50. data/lib/language_operator/cli/errors/suggestions.rb +176 -0
  51. data/lib/language_operator/cli/formatters/code_formatter.rb +77 -0
  52. data/lib/language_operator/cli/formatters/log_formatter.rb +288 -0
  53. data/lib/language_operator/cli/formatters/progress_formatter.rb +49 -0
  54. data/lib/language_operator/cli/formatters/status_formatter.rb +37 -0
  55. data/lib/language_operator/cli/formatters/table_formatter.rb +163 -0
  56. data/lib/language_operator/cli/formatters/value_formatter.rb +113 -0
  57. data/lib/language_operator/cli/helpers/cluster_context.rb +62 -0
  58. data/lib/language_operator/cli/helpers/cluster_validator.rb +101 -0
  59. data/lib/language_operator/cli/helpers/editor_helper.rb +58 -0
  60. data/lib/language_operator/cli/helpers/kubeconfig_validator.rb +167 -0
  61. data/lib/language_operator/cli/helpers/pastel_helper.rb +24 -0
  62. data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +74 -0
  63. data/lib/language_operator/cli/helpers/schedule_builder.rb +108 -0
  64. data/lib/language_operator/cli/helpers/user_prompts.rb +69 -0
  65. data/lib/language_operator/cli/main.rb +236 -0
  66. data/lib/language_operator/cli/templates/tools/generic.yaml +66 -0
  67. data/lib/language_operator/cli/wizards/agent_wizard.rb +246 -0
  68. data/lib/language_operator/cli/wizards/quickstart_wizard.rb +588 -0
  69. data/lib/language_operator/client/base.rb +214 -0
  70. data/lib/language_operator/client/config.rb +136 -0
  71. data/lib/language_operator/client/cost_calculator.rb +37 -0
  72. data/lib/language_operator/client/mcp_connector.rb +123 -0
  73. data/lib/language_operator/client.rb +19 -0
  74. data/lib/language_operator/config/cluster_config.rb +101 -0
  75. data/lib/language_operator/config/tool_patterns.yaml +57 -0
  76. data/lib/language_operator/config/tool_registry.rb +96 -0
  77. data/lib/language_operator/config.rb +138 -0
  78. data/lib/language_operator/dsl/adapter.rb +124 -0
  79. data/lib/language_operator/dsl/agent_context.rb +90 -0
  80. data/lib/language_operator/dsl/agent_definition.rb +427 -0
  81. data/lib/language_operator/dsl/chat_endpoint_definition.rb +115 -0
  82. data/lib/language_operator/dsl/config.rb +119 -0
  83. data/lib/language_operator/dsl/context.rb +50 -0
  84. data/lib/language_operator/dsl/execution_context.rb +47 -0
  85. data/lib/language_operator/dsl/helpers.rb +109 -0
  86. data/lib/language_operator/dsl/http.rb +184 -0
  87. data/lib/language_operator/dsl/mcp_server_definition.rb +73 -0
  88. data/lib/language_operator/dsl/parameter_definition.rb +124 -0
  89. data/lib/language_operator/dsl/registry.rb +36 -0
  90. data/lib/language_operator/dsl/schema.rb +1102 -0
  91. data/lib/language_operator/dsl/shell.rb +125 -0
  92. data/lib/language_operator/dsl/tool_definition.rb +112 -0
  93. data/lib/language_operator/dsl/webhook_authentication.rb +114 -0
  94. data/lib/language_operator/dsl/webhook_definition.rb +106 -0
  95. data/lib/language_operator/dsl/workflow_definition.rb +259 -0
  96. data/lib/language_operator/dsl.rb +161 -0
  97. data/lib/language_operator/errors.rb +60 -0
  98. data/lib/language_operator/kubernetes/client.rb +279 -0
  99. data/lib/language_operator/kubernetes/resource_builder.rb +194 -0
  100. data/lib/language_operator/loggable.rb +47 -0
  101. data/lib/language_operator/logger.rb +141 -0
  102. data/lib/language_operator/retry.rb +123 -0
  103. data/lib/language_operator/retryable.rb +132 -0
  104. data/lib/language_operator/templates/README.md +23 -0
  105. data/lib/language_operator/templates/examples/agent_synthesis.tmpl +115 -0
  106. data/lib/language_operator/templates/examples/persona_distillation.tmpl +19 -0
  107. data/lib/language_operator/templates/schema/.gitkeep +0 -0
  108. data/lib/language_operator/templates/schema/CHANGELOG.md +93 -0
  109. data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +306 -0
  110. data/lib/language_operator/templates/schema/agent_dsl_schema.json +452 -0
  111. data/lib/language_operator/tool_loader.rb +242 -0
  112. data/lib/language_operator/validators.rb +170 -0
  113. data/lib/language_operator/version.rb +1 -1
  114. data/lib/language_operator.rb +65 -3
  115. data/requirements/tasks/challenge.md +9 -0
  116. data/requirements/tasks/iterate.md +36 -0
  117. data/requirements/tasks/optimize.md +21 -0
  118. data/requirements/tasks/tag.md +5 -0
  119. data/test_agent_dsl.rb +108 -0
  120. metadata +507 -20
@@ -0,0 +1,175 @@
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
+ # Budget Tracker for enforcing cost and token limits
10
+ #
11
+ # Tracks cumulative costs and token usage per agent instance
12
+ # and enforces configured budgets.
13
+ #
14
+ # @example
15
+ # tracker = BudgetTracker.new(daily_budget: 10.0, hourly_budget: 1.0)
16
+ # tracker.check_budget!(estimated_cost: 0.05, estimated_tokens: 500)
17
+ class BudgetTracker
18
+ include LanguageOperator::Loggable
19
+
20
+ attr_reader :daily_budget, :hourly_budget, :token_budget
21
+
22
+ class BudgetExceededError < StandardError; end
23
+
24
+ def initialize(daily_budget: nil, hourly_budget: nil, token_budget: nil)
25
+ @daily_budget = daily_budget&.to_f
26
+ @hourly_budget = hourly_budget&.to_f
27
+ @token_budget = token_budget&.to_i
28
+
29
+ @daily_spending = 0.0
30
+ @hourly_spending = 0.0
31
+ @daily_tokens = 0
32
+ @hourly_tokens = 0
33
+
34
+ @day_start = Time.now
35
+ @hour_start = Time.now
36
+
37
+ logger.info('Budget tracker initialized',
38
+ daily_budget: @daily_budget&.round(2),
39
+ hourly_budget: @hourly_budget&.round(2),
40
+ token_budget: @token_budget)
41
+ end
42
+
43
+ # Check if a request would exceed budget limits
44
+ #
45
+ # @param estimated_cost [Float] Estimated cost for the request
46
+ # @param estimated_tokens [Integer] Estimated token count
47
+ # @raise [BudgetExceededError] If budget would be exceeded
48
+ def check_budget!(estimated_cost: 0.0, estimated_tokens: 0)
49
+ reset_if_needed
50
+
51
+ # Check daily budget
52
+ if @daily_budget && (@daily_spending + estimated_cost) > @daily_budget
53
+ remaining = [@daily_budget - @daily_spending, 0].max
54
+ logger.error('Daily budget exceeded',
55
+ current: @daily_spending.round(4),
56
+ limit: @daily_budget,
57
+ remaining: remaining.round(4),
58
+ requested: estimated_cost.round(4))
59
+ raise BudgetExceededError,
60
+ "Daily budget exceeded: $#{@daily_spending.round(4)}/$#{@daily_budget} " \
61
+ "(requested: $#{estimated_cost.round(4)})"
62
+ end
63
+
64
+ # Check hourly budget
65
+ if @hourly_budget && (@hourly_spending + estimated_cost) > @hourly_budget
66
+ remaining = [@hourly_budget - @hourly_spending, 0].max
67
+ logger.error('Hourly budget exceeded',
68
+ current: @hourly_spending.round(4),
69
+ limit: @hourly_budget,
70
+ remaining: remaining.round(4),
71
+ requested: estimated_cost.round(4))
72
+ raise BudgetExceededError,
73
+ "Hourly budget exceeded: $#{@hourly_spending.round(4)}/$#{@hourly_budget} " \
74
+ "(requested: $#{estimated_cost.round(4)})"
75
+ end
76
+
77
+ # Check token budget
78
+ if @token_budget && (@daily_tokens + estimated_tokens) > @token_budget
79
+ remaining = [@token_budget - @daily_tokens, 0].max
80
+ logger.error('Token budget exceeded',
81
+ current: @daily_tokens,
82
+ limit: @token_budget,
83
+ remaining: remaining,
84
+ requested: estimated_tokens)
85
+ raise BudgetExceededError,
86
+ "Token budget exceeded: #{@daily_tokens}/#{@token_budget} " \
87
+ "(requested: #{estimated_tokens})"
88
+ end
89
+
90
+ logger.debug('Budget check passed',
91
+ estimated_cost: estimated_cost.round(4),
92
+ estimated_tokens: estimated_tokens)
93
+ end
94
+
95
+ # Record actual spending after a request
96
+ #
97
+ # @param cost [Float] Actual cost of the request
98
+ # @param tokens [Integer] Actual token count
99
+ def record_spending(cost:, tokens:)
100
+ reset_if_needed
101
+
102
+ @daily_spending += cost
103
+ @hourly_spending += cost
104
+ @daily_tokens += tokens
105
+ @hourly_tokens += tokens
106
+
107
+ logger.debug('Spending recorded',
108
+ cost: cost.round(4),
109
+ tokens: tokens,
110
+ daily_total: @daily_spending.round(4),
111
+ hourly_total: @hourly_spending.round(4),
112
+ daily_tokens: @daily_tokens)
113
+ end
114
+
115
+ # Get current budget status
116
+ #
117
+ # @return [Hash] Budget status information
118
+ def status
119
+ reset_if_needed
120
+
121
+ {
122
+ daily: {
123
+ spending: @daily_spending.round(4),
124
+ budget: @daily_budget&.round(2),
125
+ remaining: @daily_budget ? (@daily_budget - @daily_spending).round(4) : nil,
126
+ percentage: @daily_budget ? ((@daily_spending / @daily_budget) * 100).round(1) : nil
127
+ },
128
+ hourly: {
129
+ spending: @hourly_spending.round(4),
130
+ budget: @hourly_budget&.round(2),
131
+ remaining: @hourly_budget ? (@hourly_budget - @hourly_spending).round(4) : nil,
132
+ percentage: @hourly_budget ? ((@hourly_spending / @hourly_budget) * 100).round(1) : nil
133
+ },
134
+ tokens: {
135
+ used: @daily_tokens,
136
+ budget: @token_budget,
137
+ remaining: @token_budget ? (@token_budget - @daily_tokens) : nil,
138
+ percentage: @token_budget ? ((@daily_tokens.to_f / @token_budget) * 100).round(1) : nil
139
+ }
140
+ }
141
+ end
142
+
143
+ private
144
+
145
+ def logger_component
146
+ 'Safety::BudgetTracker'
147
+ end
148
+
149
+ # Reset counters if time periods have elapsed
150
+ def reset_if_needed
151
+ now = Time.now
152
+
153
+ # Reset daily counters
154
+ if (now - @day_start) >= 86_400 # 24 hours
155
+ logger.info('Resetting daily budget counters',
156
+ previous_spending: @daily_spending.round(4),
157
+ previous_tokens: @daily_tokens)
158
+ @daily_spending = 0.0
159
+ @daily_tokens = 0
160
+ @day_start = now
161
+ end
162
+
163
+ # Reset hourly counters
164
+ return unless (now - @hour_start) >= 3600 # 1 hour
165
+
166
+ logger.debug('Resetting hourly budget counters',
167
+ previous_spending: @hourly_spending.round(4))
168
+ @hourly_spending = 0.0
169
+ @hourly_tokens = 0
170
+ @hour_start = now
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,93 @@
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
+ # Content Filter for pre-prompt and post-response filtering
10
+ #
11
+ # Supports pattern matching for blocked keywords and topics.
12
+ # Can be extended to integrate with moderation APIs.
13
+ #
14
+ # @example
15
+ # filter = ContentFilter.new(blocked_patterns: ['password', 'secret'])
16
+ # filter.check_content!("Here is my password: 12345", direction: :input)
17
+ class ContentFilter
18
+ include LanguageOperator::Loggable
19
+
20
+ class ContentBlockedError < StandardError; end
21
+
22
+ def initialize(blocked_patterns: [], blocked_topics: [], case_sensitive: false)
23
+ @blocked_patterns = blocked_patterns || []
24
+ @blocked_topics = blocked_topics || []
25
+ @case_sensitive = case_sensitive
26
+
27
+ logger.info('Content filter initialized',
28
+ patterns: @blocked_patterns.length,
29
+ topics: @blocked_topics.length,
30
+ case_sensitive: @case_sensitive)
31
+ end
32
+
33
+ # Check content for blocked patterns
34
+ #
35
+ # @param content [String] Content to check
36
+ # @param direction [Symbol] :input or :output
37
+ # @raise [ContentBlockedError] If content contains blocked patterns
38
+ def check_content!(content, direction: :input)
39
+ return if @blocked_patterns.empty? && @blocked_topics.empty?
40
+
41
+ content_to_check = @case_sensitive ? content : content.downcase
42
+
43
+ # Check blocked patterns
44
+ @blocked_patterns.each do |pattern|
45
+ pattern_to_match = @case_sensitive ? pattern : pattern.downcase
46
+ next unless content_to_check.include?(pattern_to_match)
47
+
48
+ logger.warn('Blocked pattern detected',
49
+ direction: direction,
50
+ pattern: pattern,
51
+ content_preview: content[0..100])
52
+ raise ContentBlockedError,
53
+ "Content blocked: contains forbidden pattern '#{pattern}' in #{direction}"
54
+ end
55
+
56
+ # Check blocked topics (using regex patterns)
57
+ @blocked_topics.each do |topic|
58
+ pattern = @case_sensitive ? Regexp.new(topic) : Regexp.new(topic, Regexp::IGNORECASE)
59
+ next unless content_to_check.match?(pattern)
60
+
61
+ logger.warn('Blocked topic detected',
62
+ direction: direction,
63
+ topic: topic,
64
+ content_preview: content[0..100])
65
+ raise ContentBlockedError,
66
+ "Content blocked: matches forbidden topic '#{topic}' in #{direction}"
67
+ end
68
+
69
+ logger.debug('Content check passed',
70
+ direction: direction,
71
+ content_length: content.length)
72
+ end
73
+
74
+ # Check if content would be blocked (without raising)
75
+ #
76
+ # @param content [String] Content to check
77
+ # @return [Boolean] True if content would be blocked
78
+ def blocked?(content)
79
+ check_content!(content)
80
+ false
81
+ rescue ContentBlockedError
82
+ true
83
+ end
84
+
85
+ private
86
+
87
+ def logger_component
88
+ 'Safety::ContentFilter'
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -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