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
@@ -0,0 +1,76 @@
1
+ require "json"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ class OpenAI < Base
6
+ API_URL = URI("https://api.openai.com/v1/chat/completions")
7
+
8
+ def call(request)
9
+ body = build_body(request)
10
+ raw = post(body, request.timeout)
11
+ data = JSON.parse(raw)
12
+
13
+ handle_error!(data)
14
+
15
+ content = data.dig("choices", 0, "message", "content").to_s.strip
16
+ usage = data["usage"] || {}
17
+
18
+ build_response(
19
+ content: content,
20
+ provider: :openai,
21
+ model: data["model"] || request.model,
22
+ usage: { prompt: usage["prompt_tokens"], completion: usage["completion_tokens"] },
23
+ raw: raw
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ def build_body(request)
30
+ body = {
31
+ model: request.model,
32
+ messages: request.messages,
33
+ temperature: request.temperature
34
+ }
35
+ body[:response_format] = { type: "json_object" } if request.response_format == :json
36
+ body.to_json
37
+ end
38
+
39
+ def post(body, timeout)
40
+ ActiveHarness.config.http_client.post(
41
+ API_URL,
42
+ headers: {
43
+ "Content-Type" => "application/json",
44
+ "Authorization" => "Bearer #{api_key}"
45
+ },
46
+ body: body,
47
+ timeout: timeout
48
+ )
49
+ end
50
+
51
+ def handle_error!(data)
52
+ return unless data["error"]
53
+
54
+ message = data.dig("error", "message").to_s
55
+ code = data.dig("error", "code").to_s
56
+
57
+ case code
58
+ when "invalid_api_key", "unauthorized"
59
+ raise Errors::InvalidApiKeyError, message
60
+ when "rate_limit_exceeded"
61
+ raise Errors::RateLimitError, message
62
+ when "content_filter"
63
+ raise Errors::SafetyBlockedError, message
64
+ else
65
+ raise Errors::InvalidRequestError, message
66
+ end
67
+ end
68
+
69
+ def api_key
70
+ key = ActiveHarness.config.openai_api_key
71
+ raise Errors::InvalidApiKeyError, "OPENAI_API_KEY not configured" if key.nil? || key.empty?
72
+ key
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,80 @@
1
+ require "json"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ # OpenRouter proxies many models (OpenAI, Anthropic, etc.) through a single API.
6
+ # API is OpenAI-compatible with an extra "HTTP-Referer" header.
7
+ class OpenRouter < Base
8
+ API_URL = URI("https://openrouter.ai/api/v1/chat/completions")
9
+
10
+ def call(request)
11
+ body = build_body(request)
12
+ raw = post(body, request.timeout)
13
+ data = JSON.parse(raw)
14
+
15
+ handle_error!(data)
16
+
17
+ content = data.dig("choices", 0, "message", "content").to_s.strip
18
+ usage = data["usage"] || {}
19
+
20
+ build_response(
21
+ content: content,
22
+ provider: :openrouter,
23
+ model: data["model"] || request.model,
24
+ usage: { prompt: usage["prompt_tokens"], completion: usage["completion_tokens"] },
25
+ raw: raw
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ def build_body(request)
32
+ body = {
33
+ model: request.model,
34
+ messages: request.messages,
35
+ temperature: request.temperature
36
+ }
37
+ body[:response_format] = { type: "json_object" } if request.response_format == :json
38
+ body.to_json
39
+ end
40
+
41
+ def post(body, timeout)
42
+ ActiveHarness.config.http_client.post(
43
+ API_URL,
44
+ headers: {
45
+ "Content-Type" => "application/json",
46
+ "Authorization" => "Bearer #{api_key}",
47
+ "HTTP-Referer" => "https://github.com/the-teacher/ActiveHarness"
48
+ },
49
+ body: body,
50
+ timeout: timeout
51
+ )
52
+ end
53
+
54
+ def handle_error!(data)
55
+ return unless data["error"]
56
+
57
+ error = data["error"]
58
+ message = error["message"].to_s
59
+ code = error["code"].to_s
60
+ meta = error.reject { |k, _| k == "message" }
61
+ full = meta.empty? ? message : "#{message} | #{meta.inspect}"
62
+
63
+ case code
64
+ when "401"
65
+ raise Errors::InvalidApiKeyError, full
66
+ when "429"
67
+ raise Errors::RateLimitError, full
68
+ else
69
+ raise Errors::InvalidRequestError, full
70
+ end
71
+ end
72
+
73
+ def api_key
74
+ key = ActiveHarness.config.openrouter_api_key
75
+ raise Errors::InvalidApiKeyError, "OPENROUTER_API_KEY not configured" if key.nil? || key.empty?
76
+ key
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,50 @@
1
+ module ActiveHarness
2
+ module RateLimit
3
+ # Sliding-window rate limiter. Tracks request timestamps per user_id
4
+ # and raises if the limit is exceeded within the rolling time window.
5
+ #
6
+ # Storage is in-memory (per-process). For multi-process environments,
7
+ # replace with a shared backend (Redis, Memcached, etc.) by subclassing
8
+ # and overriding #timestamps_for / #record_timestamp.
9
+ #
10
+ # Usage:
11
+ # limiter = RequestLimiter.new(max_requests: 10, window_seconds: 60)
12
+ # limiter.check!(user_id) # call before each request; raises if over limit
13
+ #
14
+ class RequestLimiter
15
+ DEFAULT_MAX = 10
16
+ DEFAULT_WINDOW = 60 # seconds
17
+
18
+ # @param max_requests [Integer] maximum allowed requests per window
19
+ # @param window_seconds [Integer] length of the sliding window in seconds
20
+ def initialize(max_requests: DEFAULT_MAX, window_seconds: DEFAULT_WINDOW)
21
+ @max_requests = max_requests
22
+ @window_seconds = window_seconds
23
+ @log = Hash.new { |h, k| h[k] = [] }
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ # Records this request and raises if the limit has been reached.
28
+ # @param user_id [String, Integer, nil] nil disables the check
29
+ # @raise [Errors::RequestThrottledError]
30
+ def check!(user_id)
31
+ return if user_id.nil?
32
+
33
+ key = user_id.to_s
34
+ now = Time.now.to_f
35
+ cutoff = now - @window_seconds
36
+
37
+ @mutex.synchronize do
38
+ @log[key].reject! { |t| t < cutoff }
39
+
40
+ if @log[key].size >= @max_requests
41
+ raise Errors::RequestThrottledError,
42
+ "Rate limit exceeded: #{@max_requests} requests/#{@window_seconds}s (user: #{key})"
43
+ end
44
+
45
+ @log[key] << now
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,69 @@
1
+ module ActiveHarness
2
+ module RateLimit
3
+ # Progressive hold-back for users who repeatedly submit risky requests.
4
+ #
5
+ # Every RISKY_THRESHOLD blocked requests trigger a hold. The hold duration
6
+ # escalates with each offense:
7
+ #
8
+ # Offense 1 (3rd risky request) → 5 minutes
9
+ # Offense 2 (6th risky request) → 30 minutes
10
+ # Offense 3+ (9th+ risky request) → 2 hours
11
+ #
12
+ # Storage is in-memory (per-process). Thread-safe via Mutex.
13
+ #
14
+ # Usage:
15
+ # holdback = RiskHoldback.new
16
+ # holdback.check!(user_id) # before each request; raises if held
17
+ # holdback.record_risky!(user_id) # after guard blocks a request
18
+ #
19
+ class RiskHoldback
20
+ RISKY_THRESHOLD = 3
21
+ HOLD_SCHEDULE = [5 * 60, 30 * 60, 2 * 60 * 60].freeze # 5 min / 30 min / 2 h
22
+
23
+ # @param risky_threshold [Integer] number of risky requests before a hold is applied
24
+ def initialize(risky_threshold: RISKY_THRESHOLD)
25
+ @risky_threshold = risky_threshold
26
+ @state = {}
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ # Records a risky (guard-blocked) request for the user.
31
+ # Applies a hold when the count reaches the next threshold multiple.
32
+ # @param user_id [String, Integer, nil] nil is a no-op
33
+ def record_risky!(user_id)
34
+ return if user_id.nil?
35
+
36
+ key = user_id.to_s
37
+ @mutex.synchronize do
38
+ s = @state[key] ||= { risky_count: 0, offense_count: 0, held_until: nil }
39
+ s[:risky_count] += 1
40
+
41
+ if (s[:risky_count] % @risky_threshold).zero?
42
+ offense_idx = [s[:offense_count], HOLD_SCHEDULE.size - 1].min
43
+ s[:held_until] = Time.now.to_f + HOLD_SCHEDULE[offense_idx]
44
+ s[:offense_count] += 1
45
+ end
46
+ end
47
+ end
48
+
49
+ # Raises if the user is currently held back due to risky behaviour.
50
+ # @param user_id [String, Integer, nil] nil disables the check
51
+ # @raise [Errors::UserHoldbackError]
52
+ def check!(user_id)
53
+ return if user_id.nil?
54
+
55
+ key = user_id.to_s
56
+ @mutex.synchronize do
57
+ s = @state[key]
58
+ return unless s&.dig(:held_until)
59
+ return if Time.now.to_f >= s[:held_until]
60
+
61
+ remaining = (s[:held_until] - Time.now.to_f).ceil
62
+ raise Errors::UserHoldbackError,
63
+ "Requests paused due to repeated safety violations. " \
64
+ "Retry in #{remaining}s (user: #{key})"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,19 @@
1
+ module ActiveHarness
2
+ # Available only when ActiveHarness.config.debug == true
3
+ class DebugResult
4
+ attr_reader :system_prompt,
5
+ :guard_runs,
6
+ :callback_log
7
+
8
+ def initialize(system_prompt: nil,
9
+ guard_runs: [], callback_log: [])
10
+ @system_prompt = system_prompt
11
+ @guard_runs = guard_runs
12
+ @callback_log = callback_log
13
+ end
14
+
15
+ def full_prompt
16
+ @system_prompt.to_s
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,27 @@
1
+ module ActiveHarness
2
+ # Holds the result of the guard (input safety) layer.
3
+ class InputResult
4
+ attr_reader :raw, :processed, :errors, :risk_level, :intent, :reason
5
+
6
+ def initialize(raw:, processed:, safe:, valid:,
7
+ risk_level: :low, errors: [],
8
+ intent: nil, reason: nil)
9
+ @raw = raw
10
+ @processed = processed
11
+ @safe = safe
12
+ @valid = valid
13
+ @risk_level = risk_level
14
+ @errors = errors
15
+ @intent = intent
16
+ @reason = reason
17
+ end
18
+
19
+ def safe?
20
+ @safe
21
+ end
22
+
23
+ def valid?
24
+ @valid
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,55 @@
1
+ module ActiveHarness
2
+ class Result
3
+ SUCCESS = :success
4
+ FAILED = :failed
5
+ BLOCKED = :blocked
6
+
7
+ attr_reader :input, :output, :raw_response,
8
+ :provider, :model, :usage, :attempts,
9
+ :debug, :error
10
+
11
+ def initialize(status:, input: nil, output: nil, raw_response: nil,
12
+ provider: nil, model: nil, usage: {}, attempts: [],
13
+ debug: nil, error: nil)
14
+ @status = status
15
+ @input = input
16
+ @output = output
17
+ @raw_response = raw_response
18
+ @provider = provider
19
+ @model = model
20
+ @usage = usage
21
+ @attempts = attempts
22
+ @debug = debug
23
+ @error = error
24
+ end
25
+
26
+ def success?
27
+ @status == SUCCESS
28
+ end
29
+
30
+ def failed?
31
+ @status == FAILED
32
+ end
33
+
34
+ def blocked?
35
+ @status == BLOCKED
36
+ end
37
+
38
+ # Factory helpers
39
+
40
+ def self.success(input:, output:, raw_response:, provider:, model:,
41
+ usage:, attempts:, debug: nil)
42
+ new(status: SUCCESS, input: input, output: output,
43
+ raw_response: raw_response, provider: provider,
44
+ model: model, usage: usage, attempts: attempts, debug: debug)
45
+ end
46
+
47
+ def self.blocked(input:, output: nil, debug: nil)
48
+ new(status: BLOCKED, input: input, output: output, debug: debug)
49
+ end
50
+
51
+ def self.failed(error: nil, input: nil, debug: nil)
52
+ new(status: FAILED, error: error, input: input, debug: debug)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,49 @@
1
+ require "active_harness/core/version"
2
+ require "active_harness/core/errors"
3
+ require "active_harness/core/configuration"
4
+ require "active_harness/payload"
5
+
6
+ require "active_harness/http/client"
7
+ require "active_harness/http/retry_policy"
8
+
9
+ require "active_harness/rate_limit/request_limiter"
10
+ require "active_harness/rate_limit/risk_holdback"
11
+
12
+ require "active_harness/models/model_request"
13
+ require "active_harness/models/model_response"
14
+ require "active_harness/results/input_result"
15
+ require "active_harness/results/debug_result"
16
+ require "active_harness/results/result"
17
+
18
+ require "active_harness/providers/base"
19
+ require "active_harness/providers/openai"
20
+ require "active_harness/providers/openrouter"
21
+ require "active_harness/providers/anthropic"
22
+ require "active_harness/providers/google"
23
+
24
+ require "active_harness/prompts/guard_system_prompt"
25
+
26
+ require "active_harness/pipeline/provider_registry"
27
+ require "active_harness/pipeline/prompt_builder"
28
+ require "active_harness/pipeline/output_parser"
29
+ require "active_harness/pipeline/fallback_runner"
30
+ require "active_harness/pipeline/guard_runner"
31
+ require "active_harness/pipeline/engine"
32
+ require "active_harness/agent"
33
+
34
+ module ActiveHarness
35
+ class << self
36
+ def configure
37
+ yield configuration
38
+ end
39
+
40
+ def configuration
41
+ @configuration ||= Configuration.new
42
+ end
43
+ alias config configuration
44
+
45
+ def reset!
46
+ @configuration = nil
47
+ end
48
+ end
49
+ end
metadata ADDED
@@ -0,0 +1,131 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_harness
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - the-teacher
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest-reporters
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: mocha
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.0'
69
+ description: |
70
+ ActiveHarness provides a DSL for describing AI agents and an engine for
71
+ their execution, with built-in prompt-injection protection (guard layer),
72
+ provider fallback chains, and a structured result API.
73
+ email:
74
+ - the-teacher@github.com
75
+ executables: []
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - LICENSE
80
+ - README.md
81
+ - lib/active_harness.rb
82
+ - lib/active_harness/agent.rb
83
+ - lib/active_harness/core/configuration.rb
84
+ - lib/active_harness/core/errors.rb
85
+ - lib/active_harness/core/version.rb
86
+ - lib/active_harness/http/client.rb
87
+ - lib/active_harness/http/retry_policy.rb
88
+ - lib/active_harness/models/model_request.rb
89
+ - lib/active_harness/models/model_response.rb
90
+ - lib/active_harness/payload.rb
91
+ - lib/active_harness/pipeline/engine.rb
92
+ - lib/active_harness/pipeline/fallback_runner.rb
93
+ - lib/active_harness/pipeline/guard_runner.rb
94
+ - lib/active_harness/pipeline/output_parser.rb
95
+ - lib/active_harness/pipeline/prompt_builder.rb
96
+ - lib/active_harness/pipeline/provider_registry.rb
97
+ - lib/active_harness/prompts/guard_system_prompt.rb
98
+ - lib/active_harness/providers/anthropic.rb
99
+ - lib/active_harness/providers/base.rb
100
+ - lib/active_harness/providers/google.rb
101
+ - lib/active_harness/providers/openai.rb
102
+ - lib/active_harness/providers/openrouter.rb
103
+ - lib/active_harness/rate_limit/request_limiter.rb
104
+ - lib/active_harness/rate_limit/risk_holdback.rb
105
+ - lib/active_harness/results/debug_result.rb
106
+ - lib/active_harness/results/input_result.rb
107
+ - lib/active_harness/results/result.rb
108
+ homepage: https://github.com/the-teacher/active_harness
109
+ licenses:
110
+ - MIT
111
+ metadata: {}
112
+ post_install_message:
113
+ rdoc_options: []
114
+ require_paths:
115
+ - lib
116
+ required_ruby_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '2.6'
121
+ required_rubygems_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ requirements: []
127
+ rubygems_version: 3.0.3.1
128
+ signing_key:
129
+ specification_version: 4
130
+ summary: DSL for describing and running AI agents with safety layers
131
+ test_files: []