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
data/lib/active_harness/agent.rb
CHANGED
|
@@ -1,257 +1,136 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
1
3
|
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
4
|
class Agent
|
|
5
|
+
# -------------------------------------------------------------------------
|
|
6
|
+
# Class-level DSL — core
|
|
7
|
+
# -------------------------------------------------------------------------
|
|
19
8
|
class << self
|
|
20
|
-
#
|
|
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).
|
|
9
|
+
# Class-level entry point.
|
|
54
10
|
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
11
|
+
# SupportAgent.call(input: "Hi")
|
|
12
|
+
# SupportAgent.call(input: "Hi", context: { user_id: 42 })
|
|
13
|
+
# SupportAgent.call(input: "Hi", memory: memory)
|
|
14
|
+
def call(input: nil, context: {}, models: nil, memory: nil, stream: nil)
|
|
15
|
+
new(input: input, context: context, models: models, memory: memory, stream: stream).call
|
|
69
16
|
end
|
|
70
17
|
|
|
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
18
|
# Each subclass gets its own isolated config hash.
|
|
172
19
|
def agent_config
|
|
173
20
|
@agent_config ||= {}
|
|
174
21
|
end
|
|
175
22
|
|
|
176
|
-
|
|
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) }
|
|
23
|
+
def inherited(subclass)
|
|
24
|
+
subclass.instance_variable_set(:@agent_config, {})
|
|
200
25
|
end
|
|
201
26
|
end
|
|
202
27
|
|
|
203
28
|
# -------------------------------------------------------------------------
|
|
204
29
|
# Instance API
|
|
205
30
|
# -------------------------------------------------------------------------
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
constraints: constraints,
|
|
219
|
-
language: language,
|
|
220
|
-
translate: translate,
|
|
221
|
-
options: options
|
|
222
|
-
}
|
|
31
|
+
attr_accessor :input
|
|
32
|
+
attr_reader :context
|
|
33
|
+
|
|
34
|
+
def initialize(input: nil, context: {}, models: nil, memory: nil, stream: nil)
|
|
35
|
+
@input = input
|
|
36
|
+
@context = context
|
|
37
|
+
@config = self.class.agent_config
|
|
38
|
+
@models_override = Array(models) if models
|
|
39
|
+
@stream = stream
|
|
40
|
+
# memory: can be passed directly or via context[:memory]
|
|
41
|
+
@memory = memory || @context[:memory]
|
|
42
|
+
run_hook(:setup)
|
|
223
43
|
end
|
|
224
44
|
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
45
|
+
# Attempts each model in order, returns the first successful Result.
|
|
46
|
+
# Raises Errors::AllModelsFailed if every model in the chain fails.
|
|
47
|
+
#
|
|
48
|
+
# Optionally accepts input and stream callback inline:
|
|
49
|
+
# agent.call("What is the capital of Japan?")
|
|
50
|
+
# agent.call("...", stream: ->(token) { print token })
|
|
51
|
+
def call(input = nil, stream: nil)
|
|
52
|
+
@input = input if input
|
|
53
|
+
@stream = stream if stream
|
|
54
|
+
@memory&.load
|
|
55
|
+
@system_prompt = resolve_system_prompt
|
|
56
|
+
run_hook(:before_call)
|
|
57
|
+
attempts = []
|
|
58
|
+
|
|
59
|
+
model_list.each do |entry|
|
|
60
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
61
|
+
response = attempt_model(entry, @system_prompt)
|
|
62
|
+
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0).round(3)
|
|
63
|
+
result = build_result(response, entry, attempts, elapsed)
|
|
64
|
+
save_to_memory(result)
|
|
65
|
+
run_hook(:after_call, result)
|
|
66
|
+
return result
|
|
67
|
+
rescue *RETRYABLE_ERRORS => e
|
|
68
|
+
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0).round(3)
|
|
69
|
+
attempts << attempt_entry(entry, e, elapsed)
|
|
70
|
+
run_hook(:retry, entry, e)
|
|
71
|
+
next
|
|
72
|
+
rescue *STOP_ERRORS => e
|
|
73
|
+
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0).round(3)
|
|
74
|
+
attempts << attempt_entry(entry, e, elapsed)
|
|
75
|
+
run_hook(:retry, entry, e)
|
|
76
|
+
raise
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
run_hook(:failure, attempts)
|
|
80
|
+
raise Errors::AllModelsFailed, "All models failed. Attempts: #{attempts.inspect}"
|
|
234
81
|
end
|
|
235
|
-
end
|
|
236
82
|
|
|
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
|
|
83
|
+
private
|
|
244
84
|
|
|
245
|
-
def
|
|
246
|
-
|
|
85
|
+
def attempt_entry(entry, error, elapsed)
|
|
86
|
+
{
|
|
87
|
+
provider: entry[:provider],
|
|
88
|
+
model: entry[:model],
|
|
89
|
+
error: error.message,
|
|
90
|
+
error_code: error.respond_to?(:error_code) ? error.error_code : nil,
|
|
91
|
+
execution_time: elapsed
|
|
92
|
+
}
|
|
247
93
|
end
|
|
248
94
|
|
|
249
|
-
def
|
|
250
|
-
|
|
95
|
+
def build_result(response, entry, attempts, elapsed)
|
|
96
|
+
raw = response[:content]
|
|
97
|
+
parsed = parse_output(raw)
|
|
98
|
+
|
|
99
|
+
Result.new(
|
|
100
|
+
input: @input,
|
|
101
|
+
output: raw,
|
|
102
|
+
parsed: parsed,
|
|
103
|
+
system_prompt: @system_prompt,
|
|
104
|
+
provider: entry[:provider],
|
|
105
|
+
model: entry[:model],
|
|
106
|
+
temperature: entry[:temperature],
|
|
107
|
+
model_list: model_list,
|
|
108
|
+
attempts: attempts,
|
|
109
|
+
execution_time: elapsed,
|
|
110
|
+
usage: response[:usage]
|
|
111
|
+
)
|
|
251
112
|
end
|
|
252
113
|
|
|
253
|
-
|
|
254
|
-
|
|
114
|
+
# Auto-save to memory if no manual record was done in after_call hook.
|
|
115
|
+
# Hooks fire after this method — if a hook calls memory.record manually,
|
|
116
|
+
# the automatic save here is still the first save (hook overrides are additive).
|
|
117
|
+
# To suppress auto-save, set @memory_auto_saved in the hook.
|
|
118
|
+
def save_to_memory(result)
|
|
119
|
+
return unless @memory
|
|
120
|
+
|
|
121
|
+
@memory.record(
|
|
122
|
+
request: @input,
|
|
123
|
+
response: result.output,
|
|
124
|
+
agent: self.class.name,
|
|
125
|
+
model: result.model
|
|
126
|
+
)
|
|
255
127
|
end
|
|
256
128
|
end
|
|
257
129
|
end
|
|
130
|
+
|
|
131
|
+
require_relative "agent/prompt"
|
|
132
|
+
require_relative "agent/hooks"
|
|
133
|
+
require_relative "agent/models"
|
|
134
|
+
require_relative "agent/providers"
|
|
135
|
+
require_relative "agent/output_parser"
|
|
136
|
+
|
|
@@ -1,38 +1,32 @@
|
|
|
1
1
|
module ActiveHarness
|
|
2
2
|
module Errors
|
|
3
|
-
|
|
4
|
-
class Error < StandardError; end
|
|
3
|
+
Error = Class.new(StandardError)
|
|
5
4
|
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
class ContextValidationError < Error; end
|
|
5
|
+
# Raised when all models in the chain fail
|
|
6
|
+
AllModelsFailed = Class.new(Error)
|
|
9
7
|
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
class TimeoutError < ProviderError; end
|
|
13
|
-
class RateLimitError < ProviderError; end
|
|
14
|
-
class ProviderUnavailableError < ProviderError; end
|
|
15
|
-
class ServerError < ProviderError; end
|
|
8
|
+
# Raised by Tribunal when every agent fails or times out
|
|
9
|
+
AllAgentsFailed = Class.new(Error)
|
|
16
10
|
|
|
17
|
-
#
|
|
18
|
-
class
|
|
19
|
-
|
|
20
|
-
class SafetyBlockedError < ProviderError; end
|
|
11
|
+
# Base for all provider-level failures — carries an optional error_code and metadata
|
|
12
|
+
class ProviderError < Error
|
|
13
|
+
attr_reader :error_code, :metadata
|
|
21
14
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
15
|
+
def initialize(message = nil, error_code: nil, metadata: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@error_code = error_code
|
|
18
|
+
@metadata = metadata
|
|
19
|
+
end
|
|
20
|
+
end
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
TimeoutError = Class.new(ProviderError)
|
|
23
|
+
RateLimitError = Class.new(ProviderError)
|
|
24
|
+
ServerError = Class.new(ProviderError)
|
|
25
|
+
ProviderUnavailableError = Class.new(ProviderError)
|
|
29
26
|
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class ThrottleError < Error; end
|
|
35
|
-
class RequestThrottledError < ThrottleError; end # sliding-window rate limit
|
|
36
|
-
class UserHoldbackError < ThrottleError; end # progressive risk hold-back
|
|
27
|
+
# Non-retryable failures
|
|
28
|
+
InvalidRequestError = Class.new(ProviderError)
|
|
29
|
+
InvalidApiKeyError = Class.new(ProviderError)
|
|
30
|
+
SafetyBlockedError = Class.new(ProviderError)
|
|
37
31
|
end
|
|
38
32
|
end
|
|
@@ -1,38 +1,27 @@
|
|
|
1
1
|
require "net/http"
|
|
2
|
-
require "json"
|
|
3
2
|
|
|
4
3
|
module ActiveHarness
|
|
5
4
|
module Http
|
|
6
|
-
# Thin 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
|
-
#
|
|
5
|
+
# Thin Net::HTTP wrapper — no external dependencies.
|
|
17
6
|
class Client
|
|
18
7
|
# @param url [URI]
|
|
19
8
|
# @param headers [Hash{String => String}]
|
|
20
|
-
# @param body [String] serialized
|
|
21
|
-
# @param timeout [Integer] seconds
|
|
9
|
+
# @param body [String] JSON-serialized body
|
|
10
|
+
# @param timeout [Integer] seconds (open + read)
|
|
22
11
|
# @return [String] raw response body
|
|
23
|
-
def post(url, headers:, body:, timeout:)
|
|
12
|
+
def post(url, headers:, body:, timeout: 30)
|
|
24
13
|
http = Net::HTTP.new(url.host, url.port)
|
|
25
14
|
http.use_ssl = true
|
|
26
|
-
http.read_timeout = timeout
|
|
27
15
|
http.open_timeout = timeout
|
|
16
|
+
http.read_timeout = timeout
|
|
28
17
|
|
|
29
|
-
req
|
|
18
|
+
req = Net::HTTP::Post.new(url)
|
|
30
19
|
headers.each { |k, v| req[k] = v }
|
|
31
20
|
req.body = body
|
|
32
21
|
|
|
33
22
|
http.request(req).body
|
|
34
|
-
rescue Net::
|
|
35
|
-
raise Errors::TimeoutError, "Request
|
|
23
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
24
|
+
raise Errors::TimeoutError, "Request to #{url.host} timed out"
|
|
36
25
|
rescue => e
|
|
37
26
|
raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
|
|
38
27
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module ActiveHarness
|
|
5
|
+
module Http
|
|
6
|
+
# Streaming variant of Client.
|
|
7
|
+
# Calls +on_token+ for each content token as it arrives via SSE.
|
|
8
|
+
# Accumulates and returns the full content string when the stream ends.
|
|
9
|
+
class StreamingClient
|
|
10
|
+
# @param url [URI]
|
|
11
|
+
# @param headers [Hash{String => String}]
|
|
12
|
+
# @param body [String] JSON-serialized body
|
|
13
|
+
# @param timeout [Integer] seconds (open + read)
|
|
14
|
+
# @param on_token [Proc] called with each partial token string
|
|
15
|
+
# @return [String] full accumulated content
|
|
16
|
+
def post(url, headers:, body:, timeout: 60, on_token:)
|
|
17
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
18
|
+
http.use_ssl = true
|
|
19
|
+
http.open_timeout = timeout
|
|
20
|
+
http.read_timeout = timeout
|
|
21
|
+
|
|
22
|
+
req = Net::HTTP::Post.new(url)
|
|
23
|
+
headers.each { |k, v| req[k] = v }
|
|
24
|
+
req.body = body
|
|
25
|
+
|
|
26
|
+
buffer = ""
|
|
27
|
+
content = ""
|
|
28
|
+
|
|
29
|
+
http.request(req) do |response|
|
|
30
|
+
response.read_body do |chunk|
|
|
31
|
+
buffer += chunk
|
|
32
|
+
while (line_end = buffer.index("\n"))
|
|
33
|
+
line = buffer.slice!(0, line_end + 1).strip
|
|
34
|
+
next unless line.start_with?("data: ")
|
|
35
|
+
|
|
36
|
+
data = line.delete_prefix("data: ")
|
|
37
|
+
next if data == "[DONE]"
|
|
38
|
+
|
|
39
|
+
parsed = JSON.parse(data)
|
|
40
|
+
token = parsed.dig("choices", 0, "delta", "content")
|
|
41
|
+
if token && !token.empty?
|
|
42
|
+
on_token.call(token)
|
|
43
|
+
content += token
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
content
|
|
50
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
51
|
+
raise Errors::TimeoutError, "Request to #{url.host} timed out"
|
|
52
|
+
rescue JSON::ParserError
|
|
53
|
+
# ignore malformed SSE chunks
|
|
54
|
+
content
|
|
55
|
+
rescue => e
|
|
56
|
+
raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module ActiveHarness
|
|
2
|
+
class Memory
|
|
3
|
+
module Adapter
|
|
4
|
+
# Contract that every adapter must implement.
|
|
5
|
+
# Subclasses override these four methods.
|
|
6
|
+
class Base
|
|
7
|
+
# Load turns for the given session from the storage backend.
|
|
8
|
+
# Must be called before read/write.
|
|
9
|
+
def open(session_id)
|
|
10
|
+
raise NotImplementedError, "#{self.class}#open not implemented"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Return a plain Array of turn hashes.
|
|
14
|
+
# Each turn has at minimum: { request:, response: }
|
|
15
|
+
def read
|
|
16
|
+
raise NotImplementedError, "#{self.class}#read not implemented"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Persist a single turn hash.
|
|
20
|
+
def write(turn)
|
|
21
|
+
raise NotImplementedError, "#{self.class}#write not implemented"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Flush buffers and release resources.
|
|
25
|
+
def close
|
|
26
|
+
raise NotImplementedError, "#{self.class}#close not implemented"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Remove all data for the current session from the backend.
|
|
30
|
+
def delete
|
|
31
|
+
raise NotImplementedError, "#{self.class}#delete not implemented"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|