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,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parser/current'
4
+
5
+ module LanguageOperator
6
+ module Agent
7
+ module Safety
8
+ # Validates synthesized Ruby code for security before execution
9
+ # Performs static analysis to detect dangerous method calls
10
+ class ASTValidator
11
+ # Gems that are safe to require (allowlist)
12
+ # These are required for agent execution and are safe
13
+ ALLOWED_REQUIRES = %w[
14
+ language_operator
15
+ ].freeze
16
+
17
+ # Dangerous methods that should never be called in synthesized code
18
+ DANGEROUS_METHODS = %w[
19
+ system exec spawn fork ` eval instance_eval class_eval module_eval
20
+ require load autoload require_relative
21
+ send __send__ public_send method __method__
22
+ const_set const_get remove_const
23
+ define_method define_singleton_method
24
+ undef_method remove_method alias_method
25
+ exit exit! abort raise fail throw
26
+ trap at_exit
27
+ open
28
+ ].freeze
29
+
30
+ # Dangerous constants that should not be accessed
31
+ DANGEROUS_CONSTANTS = %w[
32
+ File Dir IO FileUtils Pathname
33
+ Process Kernel ObjectSpace GC
34
+ Thread Fiber Mutex ConditionVariable
35
+ Socket TCPSocket UDPSocket TCPServer UDPServer
36
+ STDIN STDOUT STDERR
37
+ ].freeze
38
+
39
+ # Safe DSL methods that are allowed in agent definitions
40
+ SAFE_AGENT_METHODS = %w[
41
+ agent description persona schedule objectives objective
42
+ workflow step tool params depends_on prompt
43
+ constraints budget max_requests rate_limit content_filter
44
+ output mode webhook as_mcp_server as_chat_endpoint
45
+ ].freeze
46
+
47
+ # Safe DSL methods for tool definitions
48
+ SAFE_TOOL_METHODS = %w[
49
+ tool description parameter type required default
50
+ execute
51
+ ].freeze
52
+
53
+ # Safe helper methods available in execute blocks
54
+ SAFE_HELPER_METHODS = %w[
55
+ HTTP Shell
56
+ validate_url validate_phone validate_email
57
+ env_required env_get
58
+ truncate parse_csv
59
+ error success
60
+ ].freeze
61
+
62
+ # Safe Ruby built-in methods and classes
63
+ SAFE_BUILTINS = %w[
64
+ String Array Hash Integer Float Symbol
65
+ puts print p pp warn
66
+ true false nil
67
+ if unless case when then else elsif end
68
+ while until for break next redo retry return
69
+ begin rescue ensure
70
+ lambda proc block_given? yield
71
+ attr_reader attr_writer attr_accessor
72
+ private protected public
73
+ initialize new
74
+ ].freeze
75
+
76
+ class SecurityError < StandardError; end
77
+
78
+ def initialize
79
+ @parser = Parser::CurrentRuby.new
80
+ end
81
+
82
+ # Validate code and raise SecurityError if dangerous methods found
83
+ # @param code [String] Ruby code to validate
84
+ # @param file_path [String] Path to file (for error messages)
85
+ # @raise [SecurityError] if code contains dangerous methods
86
+ def validate!(code, file_path = '(eval)')
87
+ ast = parse_code(code, file_path)
88
+ return if ast.nil? # Empty code is safe
89
+
90
+ violations = scan_ast(ast)
91
+
92
+ return if violations.empty?
93
+
94
+ raise SecurityError, format_violations(violations, file_path)
95
+ end
96
+
97
+ # Validate code and return array of violations (non-raising version)
98
+ # @param code [String] Ruby code to validate
99
+ # @param file_path [String] Path to file (for error messages)
100
+ # @return [Array<Hash>] Array of violation hashes
101
+ def validate(code, file_path = '(eval)')
102
+ begin
103
+ ast = parse_code(code, file_path)
104
+ rescue SecurityError => e
105
+ # Convert SecurityError (which wraps Parser::SyntaxError) to violation
106
+ return [{ type: :syntax_error, message: e.message }]
107
+ end
108
+
109
+ return [] if ast.nil?
110
+
111
+ scan_ast(ast)
112
+ rescue Parser::SyntaxError => e
113
+ [{ type: :syntax_error, message: e.message }]
114
+ end
115
+
116
+ private
117
+
118
+ def parse_code(code, file_path)
119
+ buffer = Parser::Source::Buffer.new(file_path)
120
+ buffer.source = code
121
+ @parser.parse(buffer)
122
+ rescue Parser::SyntaxError => e
123
+ raise SecurityError, "Syntax error in #{file_path}: #{e.message}"
124
+ end
125
+
126
+ def scan_ast(node, violations = [])
127
+ return violations if node.nil?
128
+
129
+ case node.type
130
+ when :send
131
+ check_method_call(node, violations)
132
+ when :const
133
+ check_constant(node, violations)
134
+ when :gvar
135
+ check_global_variable(node, violations)
136
+ when :xstr
137
+ # Backtick string execution (e.g., `command`)
138
+ violations << {
139
+ type: :backtick_execution,
140
+ location: node.location.line,
141
+ message: 'Backtick command execution is not allowed'
142
+ }
143
+ end
144
+
145
+ # Recursively scan all child nodes
146
+ node.children.each do |child|
147
+ scan_ast(child, violations) if child.is_a?(Parser::AST::Node)
148
+ end
149
+
150
+ violations
151
+ end
152
+
153
+ def check_method_call(node, violations)
154
+ receiver, method_name, *args = node.children
155
+
156
+ method_str = method_name.to_s
157
+
158
+ # Special handling for require - check if it's in the allowlist
159
+ if %w[require require_relative].include?(method_str)
160
+ required_gem = extract_require_argument(args)
161
+
162
+ # Allow if in the allowlist
163
+ return if required_gem && ALLOWED_REQUIRES.include?(required_gem)
164
+
165
+ # Otherwise, add violation
166
+ violations << {
167
+ type: :dangerous_method,
168
+ method: method_str,
169
+ location: node.location.line,
170
+ message: "Dangerous method '#{method_str}' is not allowed"
171
+ }
172
+ return
173
+ end
174
+
175
+ # Check for other dangerous methods
176
+ if DANGEROUS_METHODS.include?(method_str)
177
+ violations << {
178
+ type: :dangerous_method,
179
+ method: method_str,
180
+ location: node.location.line,
181
+ message: "Dangerous method '#{method_str}' is not allowed"
182
+ }
183
+ end
184
+
185
+ # Check for File/Dir/IO operations
186
+ if receiver && receiver.type == :const
187
+ const_name = receiver.children[1].to_s
188
+ if DANGEROUS_CONSTANTS.include?(const_name)
189
+ violations << {
190
+ type: :dangerous_constant,
191
+ constant: const_name,
192
+ method: method_str,
193
+ location: node.location.line,
194
+ message: "Access to #{const_name}.#{method_str} is not allowed"
195
+ }
196
+ end
197
+ end
198
+
199
+ # Check for backtick execution (e.g., `command`)
200
+ # Note: backticks are represented as send with method name :`
201
+ return unless method_str == '`'
202
+
203
+ violations << {
204
+ type: :backtick_execution,
205
+ location: node.location.line,
206
+ message: 'Backtick command execution is not allowed'
207
+ }
208
+ end
209
+
210
+ def check_constant(node, violations)
211
+ _, const_name = node.children
212
+ const_str = const_name.to_s
213
+
214
+ # Check for dangerous constants being accessed directly
215
+ return unless DANGEROUS_CONSTANTS.include?(const_str)
216
+
217
+ violations << {
218
+ type: :dangerous_constant_access,
219
+ constant: const_str,
220
+ location: node.location.line,
221
+ message: "Direct access to #{const_str} constant is not allowed"
222
+ }
223
+ end
224
+
225
+ def check_global_variable(node, violations)
226
+ var_name = node.children[0].to_s
227
+
228
+ # Block access to dangerous global variables
229
+ dangerous_globals = %w[$0 $PROGRAM_NAME $LOAD_PATH $: $LOADED_FEATURES $"]
230
+
231
+ return unless dangerous_globals.include?(var_name)
232
+
233
+ violations << {
234
+ type: :dangerous_global,
235
+ variable: var_name,
236
+ location: node.location.line,
237
+ message: "Access to global variable #{var_name} is not allowed"
238
+ }
239
+ end
240
+
241
+ def extract_require_argument(args)
242
+ # args is an array of AST nodes representing the arguments to require
243
+ # We're looking for a string literal like 'language_operator' or "language_operator"
244
+ return nil if args.empty?
245
+
246
+ arg_node = args.first
247
+ return nil unless arg_node
248
+
249
+ # Check if it's a string literal (:str node)
250
+ return arg_node.children[0] if arg_node.type == :str
251
+
252
+ # If it's not a string literal (e.g., dynamic require), we can't verify it
253
+ nil
254
+ end
255
+
256
+ def format_violations(violations, file_path)
257
+ header = "Security violations detected in #{file_path}:\n\n"
258
+
259
+ violation_messages = violations.map do |v|
260
+ " Line #{v[:location]}: #{v[:message]}"
261
+ end
262
+
263
+ footer = "\n\nSynthesized code must only use safe DSL methods and approved helpers."
264
+ footer += "\nSafe methods include: #{SAFE_AGENT_METHODS.join(', ')}, #{SAFE_TOOL_METHODS.join(', ')}"
265
+ footer += "\nSafe helpers include: HTTP.*, Shell.run, validate_*, env_*"
266
+
267
+ header + violation_messages.join("\n") + footer
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../../logger'
5
+ require_relative '../../loggable'
6
+
7
+ module LanguageOperator
8
+ module Agent
9
+ module Safety
10
+ # Audit Logger for safety events
11
+ #
12
+ # Logs all safety-related events (blocked requests, budget exceeded, etc.)
13
+ # for compliance and debugging.
14
+ #
15
+ # @example
16
+ # audit = AuditLogger.new
17
+ # audit.log_blocked_request(reason: 'Budget exceeded', details: {...})
18
+ class AuditLogger
19
+ include LanguageOperator::Loggable
20
+
21
+ def initialize
22
+ @audit_log_path = ENV['AUDIT_LOG_PATH'] || '/tmp/langop-audit.jsonl'
23
+ logger.info('Audit logger initialized', log_path: @audit_log_path)
24
+ end
25
+
26
+ # Log a blocked request
27
+ #
28
+ # @param reason [String] Reason for blocking
29
+ # @param details [Hash] Additional details
30
+ def log_blocked_request(reason:, details: {})
31
+ log_event(
32
+ event_type: 'blocked_request',
33
+ reason: reason,
34
+ details: details
35
+ )
36
+ end
37
+
38
+ # Log a budget event
39
+ #
40
+ # @param event [String] Event description
41
+ # @param details [Hash] Budget details
42
+ def log_budget_event(event:, details: {})
43
+ log_event(
44
+ event_type: 'budget_event',
45
+ event: event,
46
+ details: details
47
+ )
48
+ end
49
+
50
+ # Log a rate limit event
51
+ #
52
+ # @param event [String] Event description
53
+ # @param details [Hash] Rate limit details
54
+ def log_rate_limit_event(event:, details: {})
55
+ log_event(
56
+ event_type: 'rate_limit_event',
57
+ event: event,
58
+ details: details
59
+ )
60
+ end
61
+
62
+ # Log a content filter event
63
+ #
64
+ # @param event [String] Event description
65
+ # @param details [Hash] Filter details
66
+ def log_content_filter_event(event:, details: {})
67
+ log_event(
68
+ event_type: 'content_filter_event',
69
+ event: event,
70
+ details: details
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ def logger_component
77
+ 'Safety::AuditLogger'
78
+ end
79
+
80
+ def log_event(event_data)
81
+ event = {
82
+ timestamp: Time.now.utc.iso8601,
83
+ agent_name: ENV['AGENT_NAME'] || 'unknown',
84
+ **event_data
85
+ }
86
+
87
+ # Log to standard logger
88
+ logger.info('Audit event', **event_data)
89
+
90
+ # Append to audit log file
91
+ begin
92
+ File.open(@audit_log_path, 'a') do |f|
93
+ f.puts(JSON.generate(event))
94
+ end
95
+ rescue StandardError => e
96
+ logger.error('Failed to write audit log',
97
+ error: e.message,
98
+ log_path: @audit_log_path)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -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