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.
- checksums.yaml +4 -4
- data/.rubocop.yml +125 -0
- data/CHANGELOG.md +88 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +284 -0
- data/LICENSE +229 -21
- data/Makefile +82 -0
- data/README.md +3 -11
- data/Rakefile +63 -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/SCHEMA_VERSION.md +250 -0
- data/docs/dsl/agent-reference.md +604 -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/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 +127 -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 +1205 -0
- data/lib/language_operator/cli/commands/cluster.rb +371 -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 +393 -0
- data/lib/language_operator/cli/commands/quickstart.rb +22 -0
- data/lib/language_operator/cli/commands/status.rb +143 -0
- data/lib/language_operator/cli/commands/system.rb +772 -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 +77 -0
- data/lib/language_operator/cli/formatters/log_formatter.rb +288 -0
- data/lib/language_operator/cli/formatters/progress_formatter.rb +49 -0
- data/lib/language_operator/cli/formatters/status_formatter.rb +37 -0
- data/lib/language_operator/cli/formatters/table_formatter.rb +163 -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/pastel_helper.rb +24 -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 +236 -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/schema.rb +1102 -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 +161 -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/templates/README.md +23 -0
- data/lib/language_operator/templates/examples/agent_synthesis.tmpl +115 -0
- data/lib/language_operator/templates/examples/persona_distillation.tmpl +19 -0
- data/lib/language_operator/templates/schema/.gitkeep +0 -0
- data/lib/language_operator/templates/schema/CHANGELOG.md +93 -0
- data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +306 -0
- data/lib/language_operator/templates/schema/agent_dsl_schema.json +452 -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 +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
|