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.
- checksums.yaml +4 -4
- data/lib/active_harness/agent/hooks.rb +75 -0
- data/lib/active_harness/agent/models.rb +147 -0
- data/lib/active_harness/agent/output_parser.rb +57 -0
- data/lib/active_harness/agent/prompt.rb +58 -0
- data/lib/active_harness/agent/providers.rb +54 -0
- data/lib/active_harness/agent.rb +107 -228
- data/lib/active_harness/core/errors.rb +22 -28
- data/lib/active_harness/http/client.rb +8 -19
- data/lib/active_harness/http/streaming_client.rb +60 -0
- data/lib/active_harness/memory/adapter/base.rb +36 -0
- data/lib/active_harness/memory/adapter/file.rb +141 -0
- data/lib/active_harness/memory.rb +212 -0
- data/lib/active_harness/pipeline/step.rb +36 -0
- data/lib/active_harness/pipeline.rb +207 -0
- data/lib/active_harness/providers/PROVIDER_CONTRACT.md +54 -0
- data/lib/active_harness/providers/anthropic.rb +76 -4
- data/lib/active_harness/providers/base.rb +41 -13
- data/lib/active_harness/providers/gemini.rb +61 -0
- data/lib/active_harness/providers/groq.rb +64 -0
- data/lib/active_harness/providers/openai.rb +39 -47
- data/lib/active_harness/providers/openrouter.rb +40 -54
- data/lib/active_harness/railtie.rb +12 -0
- data/lib/active_harness/result.rb +10 -0
- data/lib/active_harness/tribunal.rb +216 -0
- data/lib/active_harness.rb +17 -46
- data/lib/generators/active_harness/agent/agent_generator.rb +16 -0
- data/lib/generators/active_harness/agent/templates/agent.rb.tt +8 -0
- data/lib/generators/active_harness/install/install_generator.rb +54 -0
- data/lib/generators/active_harness/install/templates/agents/test_support_agent.rb +10 -0
- data/lib/generators/active_harness/install/templates/agents/test_support_guard_agent.rb +11 -0
- data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +105 -0
- data/lib/generators/active_harness/install/templates/memory/test_support_memory.rb +16 -0
- data/lib/generators/active_harness/install/templates/pipelines/test_support_pipeline.rb +31 -0
- data/lib/generators/active_harness/install/templates/prompts/test_support_guard_prompt.rb +9 -0
- data/lib/generators/active_harness/install/templates/prompts/test_support_prompt.rb +5 -0
- data/lib/generators/active_harness/install/templates/tribunals/test_support_guard_tribunal.rb +11 -0
- data/lib/generators/active_harness/memory/memory_generator.rb +16 -0
- data/lib/generators/active_harness/memory/templates/memory.rb.tt +12 -0
- data/lib/generators/active_harness/pipeline/pipeline_generator.rb +16 -0
- data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +19 -0
- data/lib/generators/active_harness/prompt/prompt_generator.rb +16 -0
- data/lib/generators/active_harness/prompt/templates/prompt.rb.tt +5 -0
- data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +7 -0
- data/lib/generators/active_harness/tribunal/tribunal_generator.rb +16 -0
- metadata +42 -72
- data/LICENSE +0 -21
- data/README.md +0 -113
- data/lib/active_harness/core/configuration.rb +0 -55
- data/lib/active_harness/core/version.rb +0 -3
- data/lib/active_harness/http/retry_policy.rb +0 -47
- data/lib/active_harness/models/model_request.rb +0 -14
- data/lib/active_harness/models/model_response.rb +0 -13
- data/lib/active_harness/payload.rb +0 -47
- data/lib/active_harness/pipeline/engine.rb +0 -251
- data/lib/active_harness/pipeline/fallback_runner.rb +0 -76
- data/lib/active_harness/pipeline/guard_runner.rb +0 -125
- data/lib/active_harness/pipeline/output_parser.rb +0 -43
- data/lib/active_harness/pipeline/prompt_builder.rb +0 -46
- data/lib/active_harness/pipeline/provider_registry.rb +0 -16
- data/lib/active_harness/prompts/guard_system_prompt.rb +0 -33
- data/lib/active_harness/providers/google.rb +0 -11
- data/lib/active_harness/rate_limit/request_limiter.rb +0 -50
- data/lib/active_harness/rate_limit/risk_holdback.rb +0 -69
- data/lib/active_harness/results/debug_result.rb +0 -19
- data/lib/active_harness/results/input_result.rb +0 -27
- data/lib/active_harness/results/result.rb +0 -55
|
@@ -0,0 +1,216 @@
|
|
|
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_accessor :input
|
|
98
|
+
attr_reader :results, :errors, :verdict, :execution_time, :agent_execution_times
|
|
99
|
+
|
|
100
|
+
def initialize(input: nil, context: {}, agents: nil, timeout: 7)
|
|
101
|
+
config = self.class.tribunal_config
|
|
102
|
+
|
|
103
|
+
@input = input
|
|
104
|
+
@context = context
|
|
105
|
+
@agents = agents || config[:agents]
|
|
106
|
+
@timeout = timeout
|
|
107
|
+
@process_block = config[:process]
|
|
108
|
+
@hooks = config[:hooks].dup
|
|
109
|
+
@results = []
|
|
110
|
+
@errors = []
|
|
111
|
+
@verdict = nil
|
|
112
|
+
@execution_time = nil
|
|
113
|
+
@agent_execution_times = []
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Instance-level hook registration — overrides class-level hooks.
|
|
117
|
+
# :before_verdict is a transform hook: its return value replaces the results array.
|
|
118
|
+
def on(event, &block)
|
|
119
|
+
unless VALID_HOOKS.include?(event)
|
|
120
|
+
raise ArgumentError, "Unknown Tribunal hook :#{event}. Valid hooks: #{VALID_HOOKS.join(", ")}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
@hooks[event] = block
|
|
124
|
+
self
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Instance-level process block — overrides class-level block.
|
|
128
|
+
def process(&block)
|
|
129
|
+
@process_block = block
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Run all agents in parallel, then compute the verdict.
|
|
134
|
+
# Returns self so calls can be chained: tribunal.call.verdict
|
|
135
|
+
#
|
|
136
|
+
# Behaviour on failure:
|
|
137
|
+
# - If some agents fail/timeout, their errors are in #errors and
|
|
138
|
+
# #results contains only successful results.
|
|
139
|
+
# - If ALL agents fail/timeout, raises Errors::AllAgentsFailed.
|
|
140
|
+
def call
|
|
141
|
+
agents = resolve_agents
|
|
142
|
+
run_hook(:before_call)
|
|
143
|
+
|
|
144
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
145
|
+
|
|
146
|
+
futures = agents.map do |agent|
|
|
147
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
148
|
+
future = Concurrent::Future.execute { agent.call }
|
|
149
|
+
[future, t0]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
@results = []
|
|
153
|
+
@errors = []
|
|
154
|
+
@agent_execution_times = []
|
|
155
|
+
|
|
156
|
+
futures.each_with_index do |(future, t0), index|
|
|
157
|
+
future.wait(@timeout)
|
|
158
|
+
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0).round(3)
|
|
159
|
+
@agent_execution_times << { agent: agents[index].class.name, time: elapsed }
|
|
160
|
+
|
|
161
|
+
if future.fulfilled?
|
|
162
|
+
@results << future.value
|
|
163
|
+
run_hook(:after_agent, future.value)
|
|
164
|
+
elsif future.incomplete?
|
|
165
|
+
error = Errors::TimeoutError.new(
|
|
166
|
+
"Agent #{agents[index].class.name} timed out after #{@timeout}s"
|
|
167
|
+
)
|
|
168
|
+
@errors << { agent: agents[index].class.name, error: error }
|
|
169
|
+
run_hook(:agent_error, agents[index].class.name, error)
|
|
170
|
+
else
|
|
171
|
+
@errors << { agent: agents[index].class.name, error: future.reason }
|
|
172
|
+
run_hook(:agent_error, agents[index].class.name, future.reason)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
@execution_time = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at).round(3)
|
|
177
|
+
|
|
178
|
+
run_hook(:after_call, @results, @errors)
|
|
179
|
+
|
|
180
|
+
if @results.empty?
|
|
181
|
+
messages = @errors.map { |e| "#{e[:agent]}: #{e[:error].message}" }.join("; ")
|
|
182
|
+
raise Errors::AllAgentsFailed, "All agents failed — #{messages}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
verdict_input = transform_hook(:before_verdict, @results)
|
|
186
|
+
@verdict = @process_block ? @process_block.call(verdict_input) : nil
|
|
187
|
+
run_hook(:after_verdict, @verdict)
|
|
188
|
+
|
|
189
|
+
self
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
def run_hook(event, *args)
|
|
195
|
+
@hooks[event]&.call(*args)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Like run_hook but uses the return value to replace the passed value.
|
|
199
|
+
def transform_hook(event, value)
|
|
200
|
+
return value unless @hooks[event]
|
|
201
|
+
|
|
202
|
+
@hooks[event].call(value)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def resolve_agents
|
|
206
|
+
@agents.map do |agent|
|
|
207
|
+
if agent.is_a?(Class)
|
|
208
|
+
agent.new(input: @input, context: @context.dup)
|
|
209
|
+
else
|
|
210
|
+
agent.input = @input if @input
|
|
211
|
+
agent
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
data/lib/active_harness.rb
CHANGED
|
@@ -1,49 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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,16 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Generators
|
|
5
|
+
class AgentGenerator < Rails::Generators::NamedBase
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Creates an ActiveHarness agent in app/ai/agents/"
|
|
9
|
+
|
|
10
|
+
def create_agent
|
|
11
|
+
template "agent.rb.tt",
|
|
12
|
+
"app/ai/agents/#{file_name}_agent.rb"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
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,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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Generators
|
|
5
|
+
class MemoryGenerator < Rails::Generators::NamedBase
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Creates an ActiveHarness memory class in app/ai/memory/"
|
|
9
|
+
|
|
10
|
+
def create_memory
|
|
11
|
+
template "memory.rb.tt",
|
|
12
|
+
"app/ai/memory/#{file_name}_memory.rb"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class <%= class_name %>Memory < ActiveHarness::Memory
|
|
2
|
+
def initialize(session_id:)
|
|
3
|
+
super(
|
|
4
|
+
session_id: session_id,
|
|
5
|
+
depth: 10,
|
|
6
|
+
adapter: :file,
|
|
7
|
+
path: Rails.root.join("storage", "ai", "memory").to_s,
|
|
8
|
+
storage_size: 200,
|
|
9
|
+
pretty: Rails.env.development?
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Generators
|
|
5
|
+
class PipelineGenerator < Rails::Generators::NamedBase
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Creates an ActiveHarness pipeline in app/ai/pipelines/"
|
|
9
|
+
|
|
10
|
+
def create_pipeline
|
|
11
|
+
template "pipeline.rb.tt",
|
|
12
|
+
"app/ai/pipelines/#{file_name}_pipeline.rb"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class <%= class_name %>Pipeline < ActiveHarness::Pipeline
|
|
2
|
+
step :respond, <%= class_name %>Agent
|
|
3
|
+
|
|
4
|
+
before :step do |step_name, _payload|
|
|
5
|
+
puts "[pipeline] → :#{step_name}"
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
after :step do |step_name, _result|
|
|
9
|
+
puts "[pipeline] ✓ :#{step_name}"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
callback :stopped do |step_name, _result|
|
|
13
|
+
puts "[pipeline] ✗ STOPPED at :#{step_name}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
callback :complete do |_last_result|
|
|
17
|
+
puts "[pipeline] ✓ complete"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
module Generators
|
|
5
|
+
class PromptGenerator < Rails::Generators::NamedBase
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Creates an ActiveHarness prompt in app/ai/prompts/"
|
|
9
|
+
|
|
10
|
+
def create_prompt
|
|
11
|
+
template "prompt.rb.tt",
|
|
12
|
+
"app/ai/prompts/#{file_name}_prompt.rb"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|