active_harness 0.1.0 → 0.2.1

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_harness/agent/hooks.rb +75 -0
  3. data/lib/active_harness/agent/models.rb +147 -0
  4. data/lib/active_harness/agent/output_parser.rb +57 -0
  5. data/lib/active_harness/agent/prompt.rb +58 -0
  6. data/lib/active_harness/agent/providers.rb +54 -0
  7. data/lib/active_harness/agent.rb +107 -228
  8. data/lib/active_harness/core/errors.rb +22 -28
  9. data/lib/active_harness/http/client.rb +8 -19
  10. data/lib/active_harness/http/streaming_client.rb +60 -0
  11. data/lib/active_harness/memory/adapter/base.rb +36 -0
  12. data/lib/active_harness/memory/adapter/file.rb +141 -0
  13. data/lib/active_harness/memory.rb +212 -0
  14. data/lib/active_harness/pipeline/step.rb +36 -0
  15. data/lib/active_harness/pipeline.rb +207 -0
  16. data/lib/active_harness/providers/PROVIDER_CONTRACT.md +54 -0
  17. data/lib/active_harness/providers/anthropic.rb +76 -4
  18. data/lib/active_harness/providers/base.rb +41 -13
  19. data/lib/active_harness/providers/gemini.rb +61 -0
  20. data/lib/active_harness/providers/groq.rb +64 -0
  21. data/lib/active_harness/providers/openai.rb +39 -47
  22. data/lib/active_harness/providers/openrouter.rb +40 -54
  23. data/lib/active_harness/railtie.rb +12 -0
  24. data/lib/active_harness/result.rb +10 -0
  25. data/lib/active_harness/tribunal.rb +216 -0
  26. data/lib/active_harness.rb +17 -46
  27. data/lib/generators/active_harness/agent/agent_generator.rb +16 -0
  28. data/lib/generators/active_harness/agent/templates/agent.rb.tt +8 -0
  29. data/lib/generators/active_harness/install/install_generator.rb +54 -0
  30. data/lib/generators/active_harness/install/templates/agents/test_support_agent.rb +10 -0
  31. data/lib/generators/active_harness/install/templates/agents/test_support_guard_agent.rb +11 -0
  32. data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +105 -0
  33. data/lib/generators/active_harness/install/templates/memory/test_support_memory.rb +16 -0
  34. data/lib/generators/active_harness/install/templates/pipelines/test_support_pipeline.rb +31 -0
  35. data/lib/generators/active_harness/install/templates/prompts/test_support_guard_prompt.rb +9 -0
  36. data/lib/generators/active_harness/install/templates/prompts/test_support_prompt.rb +5 -0
  37. data/lib/generators/active_harness/install/templates/tribunals/test_support_guard_tribunal.rb +11 -0
  38. data/lib/generators/active_harness/memory/memory_generator.rb +16 -0
  39. data/lib/generators/active_harness/memory/templates/memory.rb.tt +12 -0
  40. data/lib/generators/active_harness/pipeline/pipeline_generator.rb +16 -0
  41. data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +19 -0
  42. data/lib/generators/active_harness/prompt/prompt_generator.rb +16 -0
  43. data/lib/generators/active_harness/prompt/templates/prompt.rb.tt +5 -0
  44. data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +7 -0
  45. data/lib/generators/active_harness/tribunal/tribunal_generator.rb +16 -0
  46. metadata +42 -72
  47. data/LICENSE +0 -21
  48. data/README.md +0 -113
  49. data/lib/active_harness/core/configuration.rb +0 -55
  50. data/lib/active_harness/core/version.rb +0 -3
  51. data/lib/active_harness/http/retry_policy.rb +0 -47
  52. data/lib/active_harness/models/model_request.rb +0 -14
  53. data/lib/active_harness/models/model_response.rb +0 -13
  54. data/lib/active_harness/payload.rb +0 -47
  55. data/lib/active_harness/pipeline/engine.rb +0 -251
  56. data/lib/active_harness/pipeline/fallback_runner.rb +0 -76
  57. data/lib/active_harness/pipeline/guard_runner.rb +0 -125
  58. data/lib/active_harness/pipeline/output_parser.rb +0 -43
  59. data/lib/active_harness/pipeline/prompt_builder.rb +0 -46
  60. data/lib/active_harness/pipeline/provider_registry.rb +0 -16
  61. data/lib/active_harness/prompts/guard_system_prompt.rb +0 -33
  62. data/lib/active_harness/providers/google.rb +0 -11
  63. data/lib/active_harness/rate_limit/request_limiter.rb +0 -50
  64. data/lib/active_harness/rate_limit/risk_holdback.rb +0 -69
  65. data/lib/active_harness/results/debug_result.rb +0 -19
  66. data/lib/active_harness/results/input_result.rb +0 -27
  67. data/lib/active_harness/results/result.rb +0 -55
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60a596cdaf7c45f9f3865c7ff7e41c3d1f2ba36cfc57eafc66a94a654bf64607
4
- data.tar.gz: b8d989e7d6435168761ed94e12d126f0025e9b6a54d7b37464f135519747bfd0
3
+ metadata.gz: e13cc7f12afbddbea9116cae2b3f5c571386ff21a5d7246107a62ceadc3253be
4
+ data.tar.gz: 8f4cdcf1333b9f10acdd664e36568e741bf09652f4b8898d028dc3bf601024d9
5
5
  SHA512:
6
- metadata.gz: b881ea07f0ec55d3116814a5080d9df5bde53773ac23182fc3c5d54cdcadb681d4bf8036570b3e3ef65a90a5e3760c0c775c0440eeacf50b4ebfcc9472849092
7
- data.tar.gz: 73f97bc713e3b12f9f7527dfce99afcbe4cce903a104d71b687abc5bf0d73a4edbc8ed6afef3aac2db3dca40e5870a3ba14a31703625e8a93a113319c3b6c357
6
+ metadata.gz: bf13a6f23ac3053316b31890505d39369bb2e932def11dcc7ecdcb2e948aed9466f91d9b1fe1b356dba35112698f7d2e901f1971e93356f32a3b34743d731e7c
7
+ data.tar.gz: 43e7202af753ffd6c6761f21ba2543498c6d04c70e52a57fb9c1aa2cb5063922f536036e28e99bc7710f7e079c50a19839ad510b97ba33841a6d6ff7b15d3220
@@ -0,0 +1,75 @@
1
+ module ActiveHarness
2
+ class Agent
3
+ VALID_HOOKS = %i[
4
+ setup
5
+ before_call
6
+ after_call
7
+ before_system_prompt
8
+ after_system_prompt
9
+ before_parse
10
+ after_parse
11
+ parse_error
12
+ retry
13
+ failure
14
+ ].freeze
15
+
16
+ class << self
17
+ # Unified hook DSL.
18
+ #
19
+ # on :setup do ... end
20
+ # on :before_call do ... end
21
+ # on :after_call do |result| ... end
22
+ # on :before_system_prompt do ... end
23
+ # on :after_system_prompt do |prompt| ... end
24
+ # on :before_parse do |raw| ... end
25
+ # on :after_parse do |parsed| ... end
26
+ # on :parse_error do |raw, error| ... end
27
+ # on :retry do |entry, error| ... end
28
+ # on :failure do |attempts| ... end
29
+ def on(event, &block)
30
+ unless VALID_HOOKS.include?(event)
31
+ raise ArgumentError, "Unknown hook :#{event}. Valid hooks: #{VALID_HOOKS.map { |h| ":#{h}" }.join(", ")}"
32
+ end
33
+
34
+ agent_config[:hooks] ||= {}
35
+ agent_config[:hooks][event] = block
36
+ end
37
+
38
+ # Rails-style aliases for +on+:
39
+ #
40
+ # before :call do ... end # → on :before_call
41
+ # before :system_prompt do ... end # → on :before_system_prompt
42
+ # after :call do |r| end # → on :after_call
43
+ # after :system_prompt do |p| end # → on :after_system_prompt
44
+ # after :parse do |p| end # → on :after_parse
45
+ # callback :retry do |e,err| end
46
+ # callback :failure do |a| end
47
+ # callback :setup do end
48
+ # callback :parse_error do |r,e| end
49
+ def before(event, &block)
50
+ on(:"before_#{event}", &block)
51
+ end
52
+
53
+ def after(event, &block)
54
+ on(:"after_#{event}", &block)
55
+ end
56
+
57
+ def callback(event, &block)
58
+ on(event, &block)
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def run_hook(event, *args)
65
+ hooks = @config[:hooks] || {}
66
+ return unless hooks[event]
67
+
68
+ if args.any?
69
+ instance_exec(*args, &hooks[event])
70
+ else
71
+ instance_eval(&hooks[event])
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,147 @@
1
+ module ActiveHarness
2
+ class Agent
3
+ class << self
4
+ # Array-style model list:
5
+ #
6
+ # models [
7
+ # { provider: :openai, model: "gpt-4.1-mini" },
8
+ # { provider: :openrouter, model: "mistralai/mistral-nemo" }
9
+ # ]
10
+ def models(list)
11
+ agent_config[:models] = Array(list)
12
+ end
13
+
14
+ # Output format for this agent.
15
+ #
16
+ # format :text # default — output is returned as-is
17
+ # format :json # output is parsed; result.parsed is a Ruby Hash/Array
18
+ def format(type)
19
+ unless %i[text json].include?(type)
20
+ raise ArgumentError, "Unknown format :#{type}. Valid values: :text, :json"
21
+ end
22
+
23
+ agent_config[:format] = type
24
+ end
25
+
26
+ # Block-based model DSL:
27
+ #
28
+ # model do
29
+ # use provider: :openrouter, model: "mistralai/mistral-nemo"
30
+ # fallback provider: :openrouter, model: "meta-llama/llama-3.3-70b-instruct:free"
31
+ # end
32
+ def model(&block)
33
+ config = ModelConfig.new
34
+ config.instance_eval(&block)
35
+ agent_config[:model] = config.to_h
36
+ end
37
+ end
38
+
39
+ # Public instance API — returns a ModelList proxy for this agent instance.
40
+ #
41
+ # Allows adding models at runtime before calling the agent:
42
+ #
43
+ # agent.models.prepend([{ provider: :openrouter, model: "..." }])
44
+ # agent.models.push([{ provider: :openrouter, model: "..." }])
45
+ #
46
+ # Any prepended/pushed models are combined with the class-defined chain:
47
+ # [prepended...] + [class chain / constructor override] + [pushed...]
48
+ def models
49
+ @model_list_proxy ||= begin
50
+ base = if @models_override&.any?
51
+ @models_override
52
+ elsif (m = @config[:model])
53
+ m[:models] || []
54
+ else
55
+ @config[:models] || []
56
+ end
57
+ ModelList.new(base)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def model_list
64
+ list = models.to_a
65
+ raise ArgumentError, "#{self.class.name}: no models configured. Use `model do...end` or `models [...]`." if list.empty?
66
+
67
+ list
68
+ end
69
+ end
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # ModelList — mutable proxy returned by agent.models
73
+ # ---------------------------------------------------------------------------
74
+ class ModelList
75
+ def initialize(base_models)
76
+ @base = Array(base_models)
77
+ @prepended = []
78
+ @appended = []
79
+ end
80
+
81
+ # Add models BEFORE the existing chain. Calling multiple times keeps order
82
+ # (last prepend ends up first).
83
+ def prepend(models)
84
+ @prepended = Array(models) + @prepended
85
+ self
86
+ end
87
+
88
+ # Add models AFTER the existing chain.
89
+ def push(models)
90
+ @appended += Array(models)
91
+ self
92
+ end
93
+
94
+ # Replace the entire chain, discarding prepended/appended too.
95
+ #
96
+ # agent.models.replace([{ provider: :openrouter, model: "..." }])
97
+ def replace(models)
98
+ @base = Array(models)
99
+ @prepended = []
100
+ @appended = []
101
+ self
102
+ end
103
+
104
+ # Insert a single model entry at an arbitrary position in the final chain.
105
+ # Position 0 is the same as prepend; -1 appends after the last element.
106
+ #
107
+ # agent.models.insert(0, { provider: :openrouter, model: "fast-model" })
108
+ # agent.models.insert(2, { provider: :openrouter, model: "mid-model" })
109
+ # agent.models.insert(-1, { provider: :openrouter, model: "last-resort" })
110
+ def insert(position, model)
111
+ to_a.tap { |list| list.insert(position, model) }.then do |new_list|
112
+ @prepended = []
113
+ @base = new_list
114
+ @appended = []
115
+ end
116
+ self
117
+ end
118
+
119
+ # Return the fully assembled list: prepended + base + appended.
120
+ def to_a
121
+ @prepended + @base + @appended
122
+ end
123
+
124
+ def inspect
125
+ "#<ModelList prepended=#{@prepended.size} base=#{@base.size} appended=#{@appended.size}>"
126
+ end
127
+ end
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # DSL builder — used by Agent.model { ... }
131
+ # ---------------------------------------------------------------------------
132
+ class ModelConfig
133
+ def initialize
134
+ @models = []
135
+ end
136
+
137
+ def use(provider:, model:, temperature: nil)
138
+ @models << { provider: provider, model: model, temperature: temperature }.compact
139
+ end
140
+
141
+ alias fallback use
142
+
143
+ def to_h
144
+ { models: @models }
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,57 @@
1
+ module ActiveHarness
2
+ class Agent
3
+ private
4
+
5
+ # Strips markdown code fences that some models add despite being told not to:
6
+ # ```json\n{...}\n``` → {...}
7
+ def strip_code_fences(raw)
8
+ raw.to_s.strip
9
+ .gsub(/\A```(?:json)?\s*/i, "")
10
+ .gsub(/\s*```\z/, "")
11
+ .strip
12
+ end
13
+
14
+ # Like run_hook but uses the block's return value to replace the current value.
15
+ # If no hook is defined, returns the original value unchanged.
16
+ #
17
+ # on :before_parse do |raw|
18
+ # raw.gsub("'", '"') # fixed-up string is used for parsing
19
+ # end
20
+ #
21
+ # on :after_parse do |parsed|
22
+ # parsed.transform_keys(&:to_sym) # symbolized hash is stored in result
23
+ # end
24
+ def transform_hook(event, value)
25
+ hooks = @config[:hooks] || {}
26
+ return value unless hooks[event]
27
+
28
+ instance_exec(value, &hooks[event])
29
+ end
30
+
31
+ def parse_output(raw)
32
+ return raw unless @config[:format] == :json
33
+
34
+ # :before_parse — return value replaces raw before stripping/parsing
35
+ raw = transform_hook(:before_parse, raw)
36
+
37
+ clean = strip_code_fences(raw)
38
+
39
+ begin
40
+ parsed = JSON.parse(clean)
41
+
42
+ # :after_parse — return value replaces parsed result stored in Result
43
+ transform_hook(:after_parse, parsed)
44
+ rescue JSON::ParserError => e
45
+ # :parse_error — if hook returns non-nil, it is used as fallback value
46
+ # and the exception is suppressed. Return nil (or omit return) to re-raise.
47
+ #
48
+ # on :parse_error do |raw, error|
49
+ # puts "Parse failed: #{error.message}"
50
+ # { "result" => nil, "reason" => "parse error" } # fallback
51
+ # end
52
+ fallback = run_hook(:parse_error, raw, e)
53
+ fallback.nil? ? raise : fallback
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,58 @@
1
+ module ActiveHarness
2
+ class Agent
3
+ class << self
4
+ # System prompt for this agent.
5
+ # Accepts:
6
+ # - a String → used as-is
7
+ # - a Class → instantiated and resolved via #call or #text
8
+ # - a Proc → called at request time (no arguments)
9
+ #
10
+ # system_prompt "You are a helpful assistant."
11
+ # system_prompt MyPromptClass
12
+ # system_prompt -> { "Dynamic prompt built at #{Time.now}" }
13
+ def system_prompt(text_or_class)
14
+ agent_config[:system_prompt] = text_or_class
15
+ end
16
+ alias prompt system_prompt
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :system_prompt
22
+ public :system_prompt
23
+
24
+ def resolve_system_prompt
25
+ run_hook(:before_system_prompt)
26
+
27
+ sp = @config[:system_prompt]
28
+
29
+ prompt = case sp
30
+ when String then sp
31
+ when NilClass then nil
32
+ when Proc then instance_exec(&sp)
33
+ when Class
34
+ instance = sp.new
35
+ inject_agent_state(instance)
36
+ if instance.respond_to?(:call) then instance.call
37
+ elsif instance.respond_to?(:text) then instance.text
38
+ else instance.to_s
39
+ end
40
+ else
41
+ inject_agent_state(sp)
42
+ if sp.respond_to?(:call) then sp.call
43
+ elsif sp.respond_to?(:text) then sp.text
44
+ else sp.to_s
45
+ end
46
+ end
47
+
48
+ run_hook(:after_system_prompt, prompt)
49
+ prompt
50
+ end
51
+
52
+ def inject_agent_state(obj)
53
+ obj.instance_variable_set(:@input, @input)
54
+ obj.instance_variable_set(:@context, @context)
55
+ obj.instance_variable_set(:@config, @config)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,54 @@
1
+ module ActiveHarness
2
+ class Agent
3
+ # Errors that allow retrying the next model in the chain
4
+ RETRYABLE_ERRORS = [
5
+ Errors::TimeoutError,
6
+ Errors::RateLimitError,
7
+ Errors::ServerError,
8
+ Errors::ProviderUnavailableError
9
+ ].freeze
10
+
11
+ # Errors that abort the entire chain immediately
12
+ STOP_ERRORS = [
13
+ Errors::InvalidRequestError,
14
+ Errors::InvalidApiKeyError,
15
+ Errors::SafetyBlockedError
16
+ ].freeze
17
+
18
+ PROVIDERS = {
19
+ openai: -> { Providers::OpenAI.new },
20
+ openrouter: -> { Providers::OpenRouter.new },
21
+ groq: -> { Providers::Groq.new },
22
+ gemini: -> { Providers::Gemini.new },
23
+ anthropic: -> { Providers::Anthropic.new }
24
+ }.freeze
25
+
26
+ private
27
+
28
+ def attempt_model(entry, system_prompt)
29
+ provider = resolve_provider(entry[:provider])
30
+ messages = build_messages(system_prompt, @input)
31
+ opts = { model: entry[:model], messages: messages }
32
+ opts[:temperature] = entry[:temperature] if entry[:temperature]
33
+ opts[:stream] = @stream if @stream
34
+ provider.call(**opts)
35
+ end
36
+
37
+ def resolve_provider(name)
38
+ factory = PROVIDERS[name.to_sym]
39
+ raise ArgumentError, "Unknown provider: #{name.inspect}. Supported: #{PROVIDERS.keys.join(', ')}" unless factory
40
+
41
+ factory.call
42
+ end
43
+
44
+ def build_messages(system_prompt, input)
45
+ # Memory is NOT auto-injected here.
46
+ # To use history in LLM context, inject it manually via a hook or prompt class.
47
+ # See ActiveHarness::Memory for examples.
48
+ messages = []
49
+ messages << { role: "system", content: system_prompt } if system_prompt
50
+ messages << { role: "user", content: input }
51
+ messages
52
+ end
53
+ end
54
+ end