active_harness 0.1.0 → 0.2.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 (57) 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 +215 -0
  26. data/lib/active_harness.rb +17 -46
  27. data/lib/generators/active_harness/install/install_generator.rb +54 -0
  28. data/lib/generators/active_harness/install/templates/agents/test_support_agent.rb +10 -0
  29. data/lib/generators/active_harness/install/templates/agents/test_support_guard_agent.rb +11 -0
  30. data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +105 -0
  31. data/lib/generators/active_harness/install/templates/memory/test_support_memory.rb +16 -0
  32. data/lib/generators/active_harness/install/templates/pipelines/test_support_pipeline.rb +31 -0
  33. data/lib/generators/active_harness/install/templates/prompts/test_support_guard_prompt.rb +9 -0
  34. data/lib/generators/active_harness/install/templates/prompts/test_support_prompt.rb +5 -0
  35. data/lib/generators/active_harness/install/templates/tribunals/test_support_guard_tribunal.rb +11 -0
  36. metadata +32 -72
  37. data/LICENSE +0 -21
  38. data/README.md +0 -113
  39. data/lib/active_harness/core/configuration.rb +0 -55
  40. data/lib/active_harness/core/version.rb +0 -3
  41. data/lib/active_harness/http/retry_policy.rb +0 -47
  42. data/lib/active_harness/models/model_request.rb +0 -14
  43. data/lib/active_harness/models/model_response.rb +0 -13
  44. data/lib/active_harness/payload.rb +0 -47
  45. data/lib/active_harness/pipeline/engine.rb +0 -251
  46. data/lib/active_harness/pipeline/fallback_runner.rb +0 -76
  47. data/lib/active_harness/pipeline/guard_runner.rb +0 -125
  48. data/lib/active_harness/pipeline/output_parser.rb +0 -43
  49. data/lib/active_harness/pipeline/prompt_builder.rb +0 -46
  50. data/lib/active_harness/pipeline/provider_registry.rb +0 -16
  51. data/lib/active_harness/prompts/guard_system_prompt.rb +0 -33
  52. data/lib/active_harness/providers/google.rb +0 -11
  53. data/lib/active_harness/rate_limit/request_limiter.rb +0 -50
  54. data/lib/active_harness/rate_limit/risk_holdback.rb +0 -69
  55. data/lib/active_harness/results/debug_result.rb +0 -19
  56. data/lib/active_harness/results/input_result.rb +0 -27
  57. data/lib/active_harness/results/result.rb +0 -55
@@ -0,0 +1,215 @@
1
+ begin
2
+ require "concurrent"
3
+ rescue LoadError
4
+ raise LoadError,
5
+ "ActiveHarness::Tribunal requires the 'concurrent-ruby' gem. " \
6
+ "Add `gem 'concurrent-ruby'` to your Gemfile."
7
+ end
8
+
9
+ module ActiveHarness
10
+ # Can be used directly or subclassed with a class-level DSL.
11
+ #
12
+ # Direct usage:
13
+ # tribunal = ActiveHarness::Tribunal.new(
14
+ # input: "Is this message toxic?",
15
+ # context: { user_id: 42 },
16
+ # agents: [ToxicityAgent, BiasAgent, SpamAgent],
17
+ # timeout: 7
18
+ # )
19
+ # tribunal.on(:after_agent) { |result| puts result.model }
20
+ # tribunal.process { |results| results.all? { |r| r.parsed["result"] == true } }
21
+ # tribunal.call
22
+ #
23
+ # Subclass with DSL:
24
+ # class ContentQualityTribunal < ActiveHarness::Tribunal
25
+ # agents PolitenessAgent, ConstructivenessAgent
26
+ # on(:after_agent) { |result| puts result.model }
27
+ # process { |results| results.all? { |r| r.parsed["result"] == true } }
28
+ # end
29
+ # ContentQualityTribunal.new(input: "...").call
30
+ #
31
+ class Tribunal
32
+ VALID_HOOKS = %i[
33
+ before_call
34
+ after_agent
35
+ agent_error
36
+ after_call
37
+ before_verdict
38
+ after_verdict
39
+ ].freeze
40
+
41
+ # -------------------------------------------------------------------------
42
+ # Class-level DSL — used when subclassing ActiveHarness::Tribunal
43
+ # -------------------------------------------------------------------------
44
+ class << self
45
+ # Declare agents at the class level.
46
+ # agents PolitenessAgent, ConstructivenessAgent
47
+ def agents(*list)
48
+ tribunal_config[:agents] = list.flatten
49
+ end
50
+
51
+ # Class-level hook registration.
52
+ # on(:after_agent) { |result| puts result.model }
53
+ def on(event, &block)
54
+ unless VALID_HOOKS.include?(event)
55
+ raise ArgumentError, "Unknown Tribunal hook :#{event}. Valid hooks: #{VALID_HOOKS.join(", ")}"
56
+ end
57
+
58
+ tribunal_config[:hooks][event] = block
59
+ end
60
+
61
+ # Rails-style aliases for +on+:
62
+ #
63
+ # before :call do ... end # → on :before_call
64
+ # before :agent do ... end # → on :before_agent (not used yet)
65
+ # before :verdict do |r| end # → on :before_verdict
66
+ # after :call do ... end # → on :after_call
67
+ # after :agent do |r| end # → on :after_agent
68
+ # after :verdict do |v| end # → on :after_verdict
69
+ # callback :agent_error do |n,e| end # → on :agent_error
70
+ def before(event, &block)
71
+ on(:"before_#{event}", &block)
72
+ end
73
+
74
+ def after(event, &block)
75
+ on(:"after_#{event}", &block)
76
+ end
77
+
78
+ def callback(event, &block)
79
+ on(event, &block)
80
+ end
81
+
82
+ # Class-level process block.
83
+ # process { |results| results.all? { |r| r.parsed["result"] == true } }
84
+ def process(&block)
85
+ tribunal_config[:process] = block
86
+ end
87
+
88
+ def tribunal_config
89
+ @tribunal_config ||= { agents: [], hooks: {} }
90
+ end
91
+
92
+ def inherited(subclass)
93
+ subclass.instance_variable_set(:@tribunal_config, { agents: [], hooks: {} })
94
+ end
95
+ end
96
+
97
+ attr_reader :results, :errors, :verdict, :execution_time, :agent_execution_times
98
+
99
+ def initialize(input: nil, context: {}, agents: nil, timeout: 7)
100
+ config = self.class.tribunal_config
101
+
102
+ @input = input
103
+ @context = context
104
+ @agents = agents || config[:agents]
105
+ @timeout = timeout
106
+ @process_block = config[:process]
107
+ @hooks = config[:hooks].dup
108
+ @results = []
109
+ @errors = []
110
+ @verdict = nil
111
+ @execution_time = nil
112
+ @agent_execution_times = []
113
+ end
114
+
115
+ # Instance-level hook registration — overrides class-level hooks.
116
+ # :before_verdict is a transform hook: its return value replaces the results array.
117
+ def on(event, &block)
118
+ unless VALID_HOOKS.include?(event)
119
+ raise ArgumentError, "Unknown Tribunal hook :#{event}. Valid hooks: #{VALID_HOOKS.join(", ")}"
120
+ end
121
+
122
+ @hooks[event] = block
123
+ self
124
+ end
125
+
126
+ # Instance-level process block — overrides class-level block.
127
+ def process(&block)
128
+ @process_block = block
129
+ self
130
+ end
131
+
132
+ # Run all agents in parallel, then compute the verdict.
133
+ # Returns self so calls can be chained: tribunal.call.verdict
134
+ #
135
+ # Behaviour on failure:
136
+ # - If some agents fail/timeout, their errors are in #errors and
137
+ # #results contains only successful results.
138
+ # - If ALL agents fail/timeout, raises Errors::AllAgentsFailed.
139
+ def call
140
+ agents = resolve_agents
141
+ run_hook(:before_call)
142
+
143
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
144
+
145
+ futures = agents.map do |agent|
146
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
147
+ future = Concurrent::Future.execute { agent.call }
148
+ [future, t0]
149
+ end
150
+
151
+ @results = []
152
+ @errors = []
153
+ @agent_execution_times = []
154
+
155
+ futures.each_with_index do |(future, t0), index|
156
+ future.wait(@timeout)
157
+ elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0).round(3)
158
+ @agent_execution_times << { agent: agents[index].class.name, time: elapsed }
159
+
160
+ if future.fulfilled?
161
+ @results << future.value
162
+ run_hook(:after_agent, future.value)
163
+ elsif future.incomplete?
164
+ error = Errors::TimeoutError.new(
165
+ "Agent #{agents[index].class.name} timed out after #{@timeout}s"
166
+ )
167
+ @errors << { agent: agents[index].class.name, error: error }
168
+ run_hook(:agent_error, agents[index].class.name, error)
169
+ else
170
+ @errors << { agent: agents[index].class.name, error: future.reason }
171
+ run_hook(:agent_error, agents[index].class.name, future.reason)
172
+ end
173
+ end
174
+
175
+ @execution_time = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at).round(3)
176
+
177
+ run_hook(:after_call, @results, @errors)
178
+
179
+ if @results.empty?
180
+ messages = @errors.map { |e| "#{e[:agent]}: #{e[:error].message}" }.join("; ")
181
+ raise Errors::AllAgentsFailed, "All agents failed — #{messages}"
182
+ end
183
+
184
+ verdict_input = transform_hook(:before_verdict, @results)
185
+ @verdict = @process_block ? @process_block.call(verdict_input) : nil
186
+ run_hook(:after_verdict, @verdict)
187
+
188
+ self
189
+ end
190
+
191
+ private
192
+
193
+ def run_hook(event, *args)
194
+ @hooks[event]&.call(*args)
195
+ end
196
+
197
+ # Like run_hook but uses the return value to replace the passed value.
198
+ def transform_hook(event, value)
199
+ return value unless @hooks[event]
200
+
201
+ @hooks[event].call(value)
202
+ end
203
+
204
+ def resolve_agents
205
+ @agents.map do |agent|
206
+ if agent.is_a?(Class)
207
+ agent.new(input: @input, context: @context.dup)
208
+ else
209
+ agent.input = @input if @input
210
+ agent
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -1,49 +1,20 @@
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"
1
+ require_relative "active_harness/core/errors"
2
+ require_relative "active_harness/result"
3
+ require_relative "active_harness/http/client"
4
+ require_relative "active_harness/http/streaming_client"
5
+ require_relative "active_harness/providers/base"
6
+ require_relative "active_harness/providers/openai"
7
+ require_relative "active_harness/providers/openrouter"
8
+ require_relative "active_harness/providers/groq"
9
+ require_relative "active_harness/providers/gemini"
10
+ require_relative "active_harness/providers/anthropic"
11
+ require_relative "active_harness/memory"
12
+ require_relative "active_harness/agent"
13
+ require_relative "active_harness/tribunal"
14
+ require_relative "active_harness/pipeline"
15
+
16
+ require_relative "active_harness/railtie" if defined?(Rails::Railtie)
33
17
 
34
18
  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
19
+ VERSION = "0.2.0"
49
20
  end
@@ -0,0 +1,54 @@
1
+ require "rails/generators"
2
+
3
+ module ActiveHarness
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ desc "Creates the app/ai/ directory structure for ActiveHarness"
7
+
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ APP_AI_DIRS = %w[agents prompts tribunals pipelines memory].freeze
11
+
12
+ def create_structure
13
+ APP_AI_DIRS.each do |dir|
14
+ empty_directory "app/ai/#{dir}"
15
+ copy_templates_if_empty(dir)
16
+ end
17
+ end
18
+
19
+ def copy_controller
20
+ target = File.join(destination_root, "app", "controllers", "ai_test_support_controller.rb")
21
+ return if File.exist?(target)
22
+
23
+ copy_file "controllers/ai_controller.rb",
24
+ "app/controllers/ai_test_support_controller.rb"
25
+ end
26
+
27
+ def inject_routes
28
+ route <<~ROUTES.strip
29
+ # ActiveHarness — AI test support endpoints
30
+ post "ai/agent", to: "ai_test_support#agent"
31
+ post "ai/agent_memory", to: "ai_test_support#agent_memory"
32
+ post "ai/tribunal", to: "ai_test_support#tribunal"
33
+ post "ai/pipeline", to: "ai_test_support#pipeline"
34
+ get "ai/agent_stream", to: "ai_test_support#agent_stream"
35
+ ROUTES
36
+ end
37
+
38
+ private
39
+
40
+ def copy_templates_if_empty(dir)
41
+ target_dir = File.join(destination_root, "app", "ai", dir)
42
+ return if Dir.glob(File.join(target_dir, "*.rb")).any?
43
+
44
+ template_dir = File.join(self.class.source_root, dir)
45
+ return unless Dir.exist?(template_dir)
46
+
47
+ Dir[File.join(template_dir, "*.rb")].sort.each do |tpl|
48
+ copy_file File.join(dir, File.basename(tpl)),
49
+ File.join("app", "ai", dir, File.basename(tpl))
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,10 @@
1
+ require_relative "../prompts/test_support_prompt"
2
+
3
+ class TestSupportAgent < ActiveHarness::Agent
4
+ system_prompt TestSupportPrompt
5
+
6
+ model do
7
+ use provider: :openrouter, model: "mistralai/mistral-nemo"
8
+ fallback provider: :openrouter, model: "meta-llama/llama-3.1-8b-instruct"
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ require_relative "../prompts/test_support_guard_prompt"
2
+
3
+ class TestSupportGuardAgent < ActiveHarness::Agent
4
+ system_prompt TestSupportGuardPrompt
5
+ format :json
6
+
7
+ model do
8
+ use provider: :openrouter, model: "meta-llama/llama-3.1-8b-instruct"
9
+ fallback provider: :openrouter, model: "mistralai/mistral-nemo"
10
+ end
11
+ end
@@ -0,0 +1,105 @@
1
+ class AiTestSupportController < ApplicationController
2
+ include ActionController::Live
3
+
4
+ # ---------------------------------------------------------------------------
5
+ # POST /ai/agent
6
+ # body: { input: "What is your return policy?" }
7
+ # ---------------------------------------------------------------------------
8
+ def agent
9
+ result = TestSupportAgent.call(input: params.require(:input))
10
+
11
+ render json: {
12
+ output: result.output,
13
+ model: result.model,
14
+ time: result.execution_time
15
+ }
16
+ end
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # POST /ai/agent_memory
20
+ # body: { input: "Does that apply to accessories?", session_id: "user_42" }
21
+ #
22
+ # Uses AppMemory so the same session keeps conversational context
23
+ # across multiple requests.
24
+ # ---------------------------------------------------------------------------
25
+ def agent_memory
26
+ memory = TestSupportMemory.new(session_id: params.require(:session_id))
27
+ result = TestSupportAgent.call(input: params.require(:input), memory: memory)
28
+
29
+ render json: {
30
+ output: result.output,
31
+ model: result.model,
32
+ time: result.execution_time,
33
+ turns: memory.size
34
+ }
35
+ end
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # POST /ai/tribunal
39
+ # body: { input: "Buy cheap pills now!!!" }
40
+ #
41
+ # Returns verdict: true (safe) or false (rejected).
42
+ # ---------------------------------------------------------------------------
43
+ def tribunal
44
+ result = TestSupportGuardTribunal.call(input: params.require(:input))
45
+
46
+ render json: {
47
+ verdict: result.verdict,
48
+ time: result.execution_time
49
+ }
50
+ end
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # POST /ai/pipeline
54
+ # body: { input: "What is your return policy?" }
55
+ #
56
+ # Runs the full TestSupportPipeline.
57
+ # If a guard step stops the pipeline early, stopped: true is returned.
58
+ # ---------------------------------------------------------------------------
59
+ def pipeline
60
+ pipe = TestSupportPipeline.new(input: params.require(:input))
61
+ pipe.call
62
+
63
+ if pipe.stopped?
64
+ render json: { stopped: true, stopped_at: pipe.stopped_at }
65
+ else
66
+ render json: { stopped: false, output: pipe.output }
67
+ end
68
+ end
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # GET /ai/agent_stream?input=What+is+your+return+policy%3F
72
+ #
73
+ # Streams the response token by token using Server-Sent Events.
74
+ # Each token arrives as: data: {"token":"..."}
75
+ # End of stream is marked: data: {"done":true}
76
+ #
77
+ # JavaScript client example:
78
+ # const es = new EventSource('/ai/agent_stream?input=Hello');
79
+ # es.onmessage = ({ data }) => {
80
+ # const { token, done } = JSON.parse(data);
81
+ # if (done) { es.close(); return; }
82
+ # document.querySelector('#output').insertAdjacentText('beforeend', token);
83
+ # };
84
+ # ---------------------------------------------------------------------------
85
+ def agent_stream
86
+ input = params.require(:input)
87
+
88
+ response.headers["Content-Type"] = "text/event-stream"
89
+ response.headers["Cache-Control"] = "no-cache"
90
+ response.headers["X-Accel-Buffering"] = "no" # disable nginx buffering
91
+
92
+ sse = ActionController::Live::SSE.new(response.stream, event: "message")
93
+
94
+ TestSupportAgent.call(
95
+ input: input,
96
+ stream: ->(token) { sse.write({ token: token }.to_json) }
97
+ )
98
+
99
+ sse.write({ done: true }.to_json)
100
+ rescue ActionController::Live::ClientDisconnected
101
+ # client closed the connection — normal, nothing to do
102
+ ensure
103
+ sse.close
104
+ end
105
+ end
@@ -0,0 +1,16 @@
1
+ class TestSupportMemory < ActiveHarness::Memory
2
+ # Usage: TestSupportMemory.new(session_id: "user_42")
3
+ #
4
+ # Wraps ActiveHarness::Memory with project defaults so callers
5
+ # only need to pass a session_id.
6
+ def initialize(session_id:)
7
+ super(
8
+ session_id: session_id,
9
+ depth: 10,
10
+ adapter: :file,
11
+ path: Rails.root.join("storage", "ai", "memory").to_s,
12
+ storage_size: 200,
13
+ pretty: Rails.env.development?
14
+ )
15
+ end
16
+ end
@@ -0,0 +1,31 @@
1
+ require_relative "../tribunals/test_support_guard_tribunal"
2
+ require_relative "../agents/test_support_agent"
3
+
4
+ # Two-step pipeline: spam guard → answer.
5
+ # Add more steps between them as needed.
6
+ class TestSupportPipeline < ActiveHarness::Pipeline
7
+ # Step 1 — GUARD: reject spam before spending tokens on an answer
8
+ step :spam_guard do
9
+ use TestSupportGuardTribunal
10
+ stop_if ->(result) { result.verdict == false }
11
+ end
12
+
13
+ # Step 2 — RESPOND: generate the actual answer
14
+ step :respond, TestSupportAgent
15
+
16
+ before :step do |step_name, _payload|
17
+ puts "[pipeline] → :#{step_name}"
18
+ end
19
+
20
+ after :step do |step_name, _result|
21
+ puts "[pipeline] ✓ :#{step_name}"
22
+ end
23
+
24
+ callback :stopped do |step_name, _result|
25
+ puts "[pipeline] ✗ STOPPED at :#{step_name}"
26
+ end
27
+
28
+ callback :complete do |_last_result|
29
+ puts "[pipeline] ✓ complete"
30
+ end
31
+ end
@@ -0,0 +1,9 @@
1
+ class TestSupportGuardPrompt
2
+ def call
3
+ <<~PROMPT.strip
4
+ You are a spam detection filter.
5
+ Analyze the message and reply ONLY with valid JSON, no markdown:
6
+ {"spam": true, "reason": "..."} or {"spam": false, "reason": "..."}
7
+ PROMPT
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ class TestSupportPrompt
2
+ def call
3
+ "You are a concise and helpful assistant. Answer in 1-2 sentences."
4
+ end
5
+ end
@@ -0,0 +1,11 @@
1
+ require_relative "../agents/test_support_guard_agent"
2
+
3
+ # Runs TestSupportGuardAgent in parallel (single agent here, extendable).
4
+ # Verdict is true (safe) when no spam is detected.
5
+ class TestSupportGuardTribunal < ActiveHarness::Tribunal
6
+ agents TestSupportGuardAgent
7
+
8
+ process do |results|
9
+ results.none? { |r| r.parsed["spam"] == true }
10
+ end
11
+ end