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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +113 -0
- data/lib/active_harness/agent.rb +257 -0
- data/lib/active_harness/core/configuration.rb +55 -0
- data/lib/active_harness/core/errors.rb +38 -0
- data/lib/active_harness/core/version.rb +3 -0
- data/lib/active_harness/http/client.rb +41 -0
- data/lib/active_harness/http/retry_policy.rb +47 -0
- data/lib/active_harness/models/model_request.rb +14 -0
- data/lib/active_harness/models/model_response.rb +13 -0
- data/lib/active_harness/payload.rb +47 -0
- data/lib/active_harness/pipeline/engine.rb +251 -0
- data/lib/active_harness/pipeline/fallback_runner.rb +76 -0
- data/lib/active_harness/pipeline/guard_runner.rb +125 -0
- data/lib/active_harness/pipeline/output_parser.rb +43 -0
- data/lib/active_harness/pipeline/prompt_builder.rb +46 -0
- data/lib/active_harness/pipeline/provider_registry.rb +16 -0
- data/lib/active_harness/prompts/guard_system_prompt.rb +33 -0
- data/lib/active_harness/providers/anthropic.rb +11 -0
- data/lib/active_harness/providers/base.rb +23 -0
- data/lib/active_harness/providers/google.rb +11 -0
- data/lib/active_harness/providers/openai.rb +76 -0
- data/lib/active_harness/providers/openrouter.rb +80 -0
- data/lib/active_harness/rate_limit/request_limiter.rb +50 -0
- data/lib/active_harness/rate_limit/risk_holdback.rb +69 -0
- data/lib/active_harness/results/debug_result.rb +19 -0
- data/lib/active_harness/results/input_result.rb +27 -0
- data/lib/active_harness/results/result.rb +55 -0
- data/lib/active_harness.rb +49 -0
- 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,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
|