active_harness 0.1.0

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 (31) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +113 -0
  4. data/lib/active_harness/agent.rb +257 -0
  5. data/lib/active_harness/core/configuration.rb +55 -0
  6. data/lib/active_harness/core/errors.rb +38 -0
  7. data/lib/active_harness/core/version.rb +3 -0
  8. data/lib/active_harness/http/client.rb +41 -0
  9. data/lib/active_harness/http/retry_policy.rb +47 -0
  10. data/lib/active_harness/models/model_request.rb +14 -0
  11. data/lib/active_harness/models/model_response.rb +13 -0
  12. data/lib/active_harness/payload.rb +47 -0
  13. data/lib/active_harness/pipeline/engine.rb +251 -0
  14. data/lib/active_harness/pipeline/fallback_runner.rb +76 -0
  15. data/lib/active_harness/pipeline/guard_runner.rb +125 -0
  16. data/lib/active_harness/pipeline/output_parser.rb +43 -0
  17. data/lib/active_harness/pipeline/prompt_builder.rb +46 -0
  18. data/lib/active_harness/pipeline/provider_registry.rb +16 -0
  19. data/lib/active_harness/prompts/guard_system_prompt.rb +33 -0
  20. data/lib/active_harness/providers/anthropic.rb +11 -0
  21. data/lib/active_harness/providers/base.rb +23 -0
  22. data/lib/active_harness/providers/google.rb +11 -0
  23. data/lib/active_harness/providers/openai.rb +76 -0
  24. data/lib/active_harness/providers/openrouter.rb +80 -0
  25. data/lib/active_harness/rate_limit/request_limiter.rb +50 -0
  26. data/lib/active_harness/rate_limit/risk_holdback.rb +69 -0
  27. data/lib/active_harness/results/debug_result.rb +19 -0
  28. data/lib/active_harness/results/input_result.rb +27 -0
  29. data/lib/active_harness/results/result.rb +55 -0
  30. data/lib/active_harness.rb +49 -0
  31. metadata +131 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 60a596cdaf7c45f9f3865c7ff7e41c3d1f2ba36cfc57eafc66a94a654bf64607
4
+ data.tar.gz: b8d989e7d6435168761ed94e12d126f0025e9b6a54d7b37464f135519747bfd0
5
+ SHA512:
6
+ metadata.gz: b881ea07f0ec55d3116814a5080d9df5bde53773ac23182fc3c5d54cdcadb681d4bf8036570b3e3ef65a90a5e3760c0c775c0440eeacf50b4ebfcc9472849092
7
+ data.tar.gz: 73f97bc713e3b12f9f7527dfce99afcbe4cce903a104d71b687abc5bf0d73a4edbc8ed6afef3aac2db3dca40e5870a3ba14a31703625e8a93a113319c3b6c357
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the-teacher
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # ActiveHarness
2
+
3
+ ActiveHarness is a lightweight framework for building production-ready AI agents in Ruby and Rails.
4
+
5
+ Instead of relying on complex orchestration frameworks, ActiveHarness provides a simple and transparent DSL to define agents as plain Ruby classes.
6
+
7
+ ## Features
8
+
9
+ - Agent-based architecture (Rails-style DSL)
10
+ - Built-in guard layer (prompt injection, toxicity, relevance checks)
11
+ - Multi-provider support: OpenAI, Anthropic, Google, OpenRouter
12
+ - Automatic fallback between models and providers
13
+ - Per-call language and translation support
14
+ - Structured outputs (text / JSON with schema validation)
15
+ - Input constraints (e.g. `max_input_length`)
16
+ - Debug mode with full prompt visibility
17
+ - Minimal dependencies, no magic
18
+
19
+ ## Installation
20
+
21
+ ```ruby
22
+ # Gemfile
23
+ gem "active_harness"
24
+ ```
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ Or install directly:
31
+
32
+ ```bash
33
+ gem install active_harness
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ### 1. Configure
39
+
40
+ ```ruby
41
+ ActiveHarness.configure do |config|
42
+ config.openai_api_key = ENV["OPENAI_API_KEY"]
43
+ config.openrouter_api_key = ENV["OPENROUTER_API_KEY"]
44
+ config.default_temperature = 0.2
45
+ config.default_timeout = 30
46
+ end
47
+ ```
48
+
49
+ ### 2. Define an agent
50
+
51
+ ```ruby
52
+ class SupportAgent < ActiveHarness::Agent
53
+ guard InjectionGuard
54
+
55
+ model do
56
+ use provider: :openai, model: "gpt-4.1-mini"
57
+ fallback provider: :openrouter, model: "meta-llama/llama-3.3-70b-instruct:free"
58
+ end
59
+
60
+ system_prompt "You are a helpful support assistant."
61
+ output :text
62
+ end
63
+ ```
64
+
65
+ ### 3. Call it
66
+
67
+ ```ruby
68
+ # One-liner
69
+ result = SupportAgent.call(input: "How do I get started?")
70
+
71
+ # Instance style
72
+ agent = SupportAgent.new(input: "How do I get started?", language: :ru)
73
+ result = agent.call
74
+
75
+ puts result.output if result.success?
76
+ puts result.output if result.blocked? # default_error_answer
77
+ ```
78
+
79
+ ## Guards
80
+
81
+ Guards run before the main request and can block it:
82
+
83
+ ```ruby
84
+ class InjectionGuard < ActiveHarness::Agent
85
+ model { use provider: :openai, model: "gpt-4.1-mini" }
86
+ system_language :en
87
+ risk_tolerance :low
88
+ end
89
+
90
+ # Register on the main agent:
91
+ guard InjectionGuard, name: :injection_guard
92
+ guard ToxicityGuard, name: :toxicity_guard
93
+ ```
94
+
95
+ ## Result API
96
+
97
+ ```ruby
98
+ result.success? # true / false
99
+ result.blocked? # true / false (guard rejected)
100
+ result.failed? # true / false (error / timeout)
101
+ result.output # String
102
+ result.model # "gpt-4.1-mini"
103
+ result.provider # :openai
104
+ ```
105
+
106
+ ## Requirements
107
+
108
+ - Ruby >= 2.6
109
+ - API key for at least one supported provider (OpenAI, Anthropic, Google, OpenRouter)
110
+
111
+ ## License
112
+
113
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,257 @@
1
+ module ActiveHarness
2
+ # DSL base class for agents and guard agents — one class, two roles.
3
+ #
4
+ # Main agent (class-method style — one-liner):
5
+ # SupportAgent.call(input: "hi", context: {})
6
+ #
7
+ # Main agent (instance style — store params, call later):
8
+ # agent = SupportAgent.new(input: "hi", language: :ru, context: {})
9
+ # result = agent.call
10
+ # result = agent.call(constraints: { max_input_length: 200 }) # merge extra params
11
+ #
12
+ # Guard agent (same class, called positionally by the engine guard chain):
13
+ # class InjectionGuard < ActiveHarness::Agent
14
+ # model { use provider: :openai, model: "gpt-4.1-mini" }
15
+ # system_prompt MyGuardPrompt
16
+ # system_language :en
17
+ # end
18
+ class Agent
19
+ class << self
20
+ # Entry point.
21
+ #
22
+ # Main mode: MyAgent.call(input: "...", context: {}, language: :en, translate: fn)
23
+ # Guard mode: MyGuard.call(payload) — called by the engine guard chain
24
+ # MyGuard.call("raw string") — manual / test use
25
+ # MyGuard.call(prev_input_result) — manual / test use
26
+ def call(*args, input: nil, context: {}, constraints: {}, language: nil, translate: nil, options: {})
27
+ if args.any?
28
+ first = args.first
29
+ payload = first.is_a?(Payload) ? first : Payload.new(
30
+ input: first.is_a?(InputResult) ? first.processed : first.to_s,
31
+ context: context,
32
+ language: language,
33
+ translate: translate,
34
+ options: options
35
+ )
36
+ call_as_guard(payload)
37
+ else
38
+ Engine.new(agent_config).call(
39
+ input: input, context: context, constraints: constraints,
40
+ language: language, translate: translate
41
+ )
42
+ end
43
+ end
44
+
45
+ # DSL -------------------------------------------------------------------
46
+
47
+ def system_language(lang)
48
+ agent_config[:system_language] = lang
49
+ end
50
+
51
+ # Registers a guard class for the safety chain (main agent only).
52
+ # Optional per-registration options are forwarded to the guard at call time.
53
+ # If name: is not given, defaults to the class name as a symbol (e.g., :InjectionGuard).
54
+ #
55
+ # Examples:
56
+ # guard InjectionGuard
57
+ # guard InjectionGuard, name: :injection_guard
58
+ # guard TopicGuard, name: :topic, allowed_topics: [:ruby, :programming]
59
+ def guard(klass, name: nil, **options)
60
+ guard_name = (name || klass.name).to_sym
61
+ agent_config[:guards] ||= []
62
+ agent_config[:guards] << { klass: klass, options: options, name: guard_name }
63
+ end
64
+
65
+ def model(&block)
66
+ config = ModelConfig.new
67
+ config.instance_eval(&block)
68
+ agent_config[:model] = config.to_h
69
+ end
70
+
71
+ def param(name, required: false)
72
+ agent_config[:params] ||= []
73
+ agent_config[:required_params] ||= []
74
+ agent_config[:params] << { name: name, required: required }
75
+ agent_config[:required_params] << name if required
76
+ end
77
+
78
+ def system_prompt(text)
79
+ agent_config[:system_prompt] = text
80
+ end
81
+ alias prompt system_prompt
82
+
83
+ def output(type, schema: nil)
84
+ agent_config[:output_type] = type
85
+ agent_config[:output_schema] = schema
86
+ end
87
+
88
+ def risk_tolerance(level)
89
+ agent_config[:risk_tolerance] = level
90
+ end
91
+
92
+ # Declares a default constraint for this agent.
93
+ # Call-time constraints (passed to .call) override agent-level defaults.
94
+ #
95
+ # Supported constraints:
96
+ # constraint :max_input_length, 500 # reject inputs longer than 500 chars
97
+ def constraint(name, value)
98
+ agent_config[:constraints] ||= {}
99
+ agent_config[:constraints][name] = value
100
+ end
101
+
102
+ # How many times to retry when a guard returns invalid/unparseable JSON.
103
+ # Overrides ActiveHarness.config.guard_retries for this specific guard.
104
+ def guard_retries(n)
105
+ agent_config[:guard_retries] = n
106
+ end
107
+
108
+ # Accepts a String or a callable (proc/lambda).
109
+ # When a callable is given, it is called with the Payload at block time:
110
+ # default_error_answer ->(payload) { payload.translate&.call("my.key") || "Fallback text" }
111
+ def default_error_answer(text_or_callable)
112
+ agent_config[:default_error_answer] = text_or_callable
113
+ end
114
+
115
+ # Initializer hook — runs once before the pipeline starts.
116
+ # Receives the Payload and must return it (optionally modified).
117
+ #
118
+ # Example:
119
+ # setup do |payload|
120
+ # payload.meta[:started_at] = Time.now
121
+ # payload.context[:locale] = determine_locale(payload.language)
122
+ # payload
123
+ # end
124
+ def setup(&block)
125
+ agent_config[:setup] = block
126
+ end
127
+
128
+ # Callbacks ------------------------------------------------------------
129
+ # All callbacks receive TWO arguments — (payload, current_value) — and
130
+ # must return the new current_value. The payload is read-only context;
131
+ # current_value is what gets threaded through the pipeline stage.
132
+ #
133
+ # Main-agent pipeline hooks:
134
+ # before(:guards) { |payload, input| input.strip } # String → String
135
+ # after(:guards) { |payload, result| result } # InputResult → InputResult
136
+ # before(:guard, :injection_guard) { |payload, input| input.downcase }
137
+ # after(:guard, :injection_guard) { |payload, result| result }
138
+ # before(:request) { |payload, prompt| prompt } # Hash → Hash
139
+ # after(:request) { |payload, response| response } # ModelResponse → ModelResponse
140
+ #
141
+ # Guard-mode hooks (when *this* class acts as a guard):
142
+ # before { |payload, input| input.strip } # String → String
143
+ # after { |payload, result| result } # InputResult → InputResult
144
+ def before(hook = nil, guard_name = nil, &block)
145
+ if hook
146
+ key = (hook == :guard && guard_name) ? :"before_guard_#{guard_name}" : :"before_#{hook}"
147
+ agent_config[:callbacks] ||= {}
148
+ agent_config[:callbacks][key] ||= []
149
+ agent_config[:callbacks][key] << block
150
+ else
151
+ agent_config[:guard_before_callbacks] ||= []
152
+ agent_config[:guard_before_callbacks] << block
153
+ end
154
+ end
155
+
156
+ def after(hook = nil, guard_name = nil, &block)
157
+ if hook
158
+ key = (hook == :guard && guard_name) ? :"after_guard_#{guard_name}" : :"after_#{hook}"
159
+ agent_config[:callbacks] ||= {}
160
+ agent_config[:callbacks][key] ||= []
161
+ agent_config[:callbacks][key] << block
162
+ else
163
+ agent_config[:guard_after_callbacks] ||= []
164
+ agent_config[:guard_after_callbacks] << block
165
+ end
166
+ end
167
+
168
+ # Debug info from the most recent guard-mode .call (class-level).
169
+ attr_reader :last_run_prompt, :last_run_response
170
+
171
+ # Each subclass gets its own isolated config hash.
172
+ def agent_config
173
+ @agent_config ||= {}
174
+ end
175
+
176
+ private
177
+
178
+ def call_as_guard(payload)
179
+ # Run the guard's own setup hook (if defined)
180
+ setup_block = agent_config[:setup]
181
+ payload = setup_block.call(payload) if setup_block
182
+
183
+ raw = payload.input
184
+
185
+ # Guard-mode before callbacks: (payload, String) → String
186
+ processed = run_guard_callbacks(agent_config[:guard_before_callbacks], payload, raw)
187
+
188
+ runner = GuardRunner.new(self, payload: payload)
189
+ result = runner.run(raw: raw, processed: processed)
190
+ @last_run_prompt = runner.last_guard_prompt
191
+ @last_run_response = runner.last_guard_response
192
+
193
+ # Guard-mode after callbacks: (payload, InputResult) → InputResult
194
+ run_guard_callbacks(agent_config[:guard_after_callbacks], payload, result)
195
+ end
196
+
197
+ # Threads +current+ through each callback as (payload, current) → new_current.
198
+ def run_guard_callbacks(callbacks, payload, current)
199
+ Array(callbacks).reduce(current) { |val, cb| cb.call(payload, val) }
200
+ end
201
+ end
202
+
203
+ # -------------------------------------------------------------------------
204
+ # Instance API
205
+ # -------------------------------------------------------------------------
206
+
207
+ # Build an agent instance with preset call parameters.
208
+ # Any keyword accepted by .call is valid here.
209
+ #
210
+ # agent = SupportAgent.new(input: "hello", language: :ru)
211
+ # result = agent.call # use stored params
212
+ # result = agent.call(constraints: { max_input_length: 200 }) # merge overrides
213
+ def initialize(input: nil, context: {}, constraints: {}, language: nil,
214
+ translate: nil, options: {})
215
+ @stored_params = {
216
+ input: input,
217
+ context: context,
218
+ constraints: constraints,
219
+ language: language,
220
+ translate: translate,
221
+ options: options
222
+ }
223
+ end
224
+
225
+ # Execute the agent. +overrides+ is merged into the stored params —
226
+ # any key present in overrides wins.
227
+ def call(**overrides)
228
+ params = @stored_params.merge(overrides) do |_key, stored, override|
229
+ # For Hash values (context, constraints) do a shallow merge so callers
230
+ # can add keys without replacing the whole hash.
231
+ (stored.is_a?(Hash) && override.is_a?(Hash)) ? stored.merge(override) : override
232
+ end
233
+ self.class.call(**params)
234
+ end
235
+ end
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Internal helper — collects the `use` + `fallback` entries inside a `model` block.
239
+ # ---------------------------------------------------------------------------
240
+ class ModelConfig
241
+ def initialize
242
+ @config = { fallbacks: [] }
243
+ end
244
+
245
+ def use(provider:, model:)
246
+ @config[:use] = { provider: provider, model: model }
247
+ end
248
+
249
+ def fallback(provider:, model:)
250
+ @config[:fallbacks] << { provider: provider, model: model }
251
+ end
252
+
253
+ def to_h
254
+ @config
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,55 @@
1
+ module ActiveHarness
2
+ class Configuration
3
+ attr_accessor :openai_api_key,
4
+ :openrouter_api_key,
5
+ :anthropic_api_key,
6
+ :google_api_key,
7
+ :default_timeout,
8
+ :default_temperature,
9
+ :default_language,
10
+ :guard_retries,
11
+ :log_requests,
12
+ :log_responses,
13
+ :debug,
14
+ :on_model_attempt,
15
+ :on_model_failure
16
+
17
+ def initialize
18
+ @openai_api_key = ENV["OPENAI_API_KEY"]
19
+ @openrouter_api_key = ENV["OPENROUTER_API_KEY"]
20
+ @anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
21
+ @google_api_key = ENV["GOOGLE_API_KEY"]
22
+
23
+ @default_timeout = 20
24
+ @default_temperature = 0.2
25
+ @default_language = :en
26
+ @guard_retries = 2 # up to 3 total attempts (1 initial + 2 retries)
27
+
28
+ @log_requests = true
29
+ @log_responses = false
30
+ @debug = false
31
+ end
32
+
33
+ # HTTP transport. Swap for a Faraday-backed client if needed:
34
+ # config.http_client = MyFaradayClient.new
35
+ # Set to nil to let providers manage their own transport.
36
+ def http_client
37
+ @http_client ||= Http::Client.new
38
+ end
39
+ attr_writer :http_client
40
+
41
+ # Sliding-window rate limiter (10 req/min per user_id by default).
42
+ # Set to nil to disable.
43
+ def request_limiter
44
+ @request_limiter ||= RateLimit::RequestLimiter.new
45
+ end
46
+ attr_writer :request_limiter
47
+
48
+ # Progressive hold-back after repeated risky requests.
49
+ # Set to nil to disable.
50
+ def risk_holdback
51
+ @risk_holdback ||= RateLimit::RiskHoldback.new
52
+ end
53
+ attr_writer :risk_holdback
54
+ end
55
+ end
@@ -0,0 +1,38 @@
1
+ module ActiveHarness
2
+ module Errors
3
+ # Base
4
+ class Error < StandardError; end
5
+
6
+ # Configuration
7
+ class ConfigurationError < Error; end
8
+ class ContextValidationError < Error; end
9
+
10
+ # Provider — retryable (fallback chain continues)
11
+ class ProviderError < Error; end
12
+ class TimeoutError < ProviderError; end
13
+ class RateLimitError < ProviderError; end
14
+ class ProviderUnavailableError < ProviderError; end
15
+ class ServerError < ProviderError; end
16
+
17
+ # Provider — terminal (fallback chain stops)
18
+ class InvalidRequestError < ProviderError; end
19
+ class InvalidApiKeyError < ProviderError; end
20
+ class SafetyBlockedError < ProviderError; end
21
+
22
+ # Output
23
+ class SchemaValidationError < Error; end
24
+ class OutputParsingError < Error; end
25
+
26
+ # Guard — raised when the guard model returns unparseable or schema-invalid JSON.
27
+ # Triggers the retry loop inside GuardRunner.
28
+ class GuardResponseError < Error; end
29
+
30
+ # Input constraints (max_input_length, etc.)
31
+ class ConstraintViolationError < Error; end
32
+
33
+ # Throttling (application-level, not provider-level)
34
+ class ThrottleError < Error; end
35
+ class RequestThrottledError < ThrottleError; end # sliding-window rate limit
36
+ class UserHoldbackError < ThrottleError; end # progressive risk hold-back
37
+ end
38
+ end
@@ -0,0 +1,3 @@
1
+ module ActiveHarness
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,41 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module ActiveHarness
5
+ module Http
6
+ # Thin HTTP POST adapter backed by Net::HTTP.
7
+ #
8
+ # To swap in Faraday (or any other client), assign a compatible object to
9
+ # ActiveHarness.config.http_client:
10
+ #
11
+ # class FaradayClient
12
+ # def post(url, headers:, body:, timeout:) = ...
13
+ # end
14
+ #
15
+ # ActiveHarness.configure { |c| c.http_client = FaradayClient.new }
16
+ #
17
+ class Client
18
+ # @param url [URI]
19
+ # @param headers [Hash{String => String}]
20
+ # @param body [String] serialized request body
21
+ # @param timeout [Integer] seconds for both open and read timeout
22
+ # @return [String] raw response body
23
+ def post(url, headers:, body:, timeout:)
24
+ http = Net::HTTP.new(url.host, url.port)
25
+ http.use_ssl = true
26
+ http.read_timeout = timeout
27
+ http.open_timeout = timeout
28
+
29
+ req = Net::HTTP::Post.new(url)
30
+ headers.each { |k, v| req[k] = v }
31
+ req.body = body
32
+
33
+ http.request(req).body
34
+ rescue Net::ReadTimeout, Net::OpenTimeout
35
+ raise Errors::TimeoutError, "Request timed out (#{url.host})"
36
+ rescue => e
37
+ raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,47 @@
1
+ module ActiveHarness
2
+ module Http
3
+ # Wraps a block with automatic retry on transient errors.
4
+ # Uses exponential backoff: delay doubles after each failed attempt.
5
+ #
6
+ # Example:
7
+ # RetryPolicy.new(max_attempts: 3, base_delay: 0.5).run do
8
+ # http_client.post(...)
9
+ # end
10
+ #
11
+ # Custom errors:
12
+ # RetryPolicy.new(errors: [MyTransientError]).run { ... }
13
+ #
14
+ class RetryPolicy
15
+ DEFAULT_ERRORS = [
16
+ Errors::TimeoutError,
17
+ Errors::RateLimitError,
18
+ Errors::ProviderUnavailableError,
19
+ Errors::ServerError
20
+ ].freeze
21
+
22
+ # @param max_attempts [Integer] total number of attempts (first + retries)
23
+ # @param base_delay [Float] delay before 1st retry in seconds; doubles each round
24
+ # @param errors [Array] error classes that trigger a retry
25
+ def initialize(max_attempts: 3, base_delay: 1.0, errors: DEFAULT_ERRORS)
26
+ @max_attempts = max_attempts
27
+ @base_delay = base_delay
28
+ @errors = errors
29
+ end
30
+
31
+ # @yieldreturn [Object] result of the block on success
32
+ # @raise last error after all attempts are exhausted
33
+ def run
34
+ attempt = 0
35
+ begin
36
+ attempt += 1
37
+ yield
38
+ rescue *@errors
39
+ raise if attempt >= @max_attempts
40
+
41
+ sleep(@base_delay * (2**(attempt - 1)))
42
+ retry
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,14 @@
1
+ module ActiveHarness
2
+ class ModelRequest
3
+ attr_reader :provider, :model, :messages, :temperature, :timeout, :response_format
4
+
5
+ def initialize(provider:, model:, messages:, temperature: nil, timeout: nil, response_format: nil)
6
+ @provider = provider
7
+ @model = model
8
+ @messages = messages
9
+ @temperature = temperature || ActiveHarness.config.default_temperature
10
+ @timeout = timeout || ActiveHarness.config.default_timeout
11
+ @response_format = response_format
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ module ActiveHarness
2
+ class ModelResponse
3
+ attr_reader :content, :provider, :model, :usage, :raw
4
+
5
+ def initialize(content:, provider:, model:, usage: {}, raw: nil)
6
+ @content = content
7
+ @provider = provider
8
+ @model = model
9
+ @usage = usage
10
+ @raw = raw
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,47 @@
1
+ module ActiveHarness
2
+ # Unified request context — the single object available in every hook.
3
+ #
4
+ # Passed as the first argument to ALL before/after callbacks and to +setup+:
5
+ #
6
+ # setup do |payload|
7
+ # payload.meta[:started_at] = Time.now
8
+ # payload # must return payload
9
+ # end
10
+ #
11
+ # before :guards do |payload, input| # input = String
12
+ # input.strip # return new String
13
+ # end
14
+ #
15
+ # after :guards do |payload, result| # result = InputResult
16
+ # result # return InputResult
17
+ # end
18
+ #
19
+ # before :request do |payload, prompt| # prompt = { system:, user: }
20
+ # prompt # return Hash
21
+ # end
22
+ #
23
+ # after :request do |payload, response| # response = ModelResponse
24
+ # response # return ModelResponse
25
+ # end
26
+ #
27
+ # Fields:
28
+ # input — raw input text; can be modified in +setup+
29
+ # context — runtime context Hash (e.g. user_id, session data)
30
+ # language — language hint for guards / responses (e.g. :ru, :en)
31
+ # translate — callable: translate.(key) → localized string; key format: "scope.agent.message"
32
+ # typically built by an I18n module: Playground::I18n.translator(locale: language)
33
+ # options — per-guard static options set at registration time
34
+ # meta — free-form Hash for user-defined data (timestamps, flags, …)
35
+ class Payload
36
+ attr_accessor :input, :context, :language, :translate, :options, :meta
37
+
38
+ def initialize(input:, context: {}, language: nil, translate: nil, options: {}, meta: {})
39
+ @input = input
40
+ @context = context
41
+ @language = language
42
+ @translate = translate
43
+ @options = options
44
+ @meta = meta
45
+ end
46
+ end
47
+ end