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
@@ -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
- # Entry point.
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
- # Examples:
56
- # guard InjectionGuard
57
- # guard InjectionGuard, name: :injection_guard
58
- # guard TopicGuard, name: :topic, allowed_topics: [:ruby, :programming]
59
- def guard(klass, name: nil, **options)
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
- private
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
- # Build an agent instance with preset call parameters.
208
- # Any keyword accepted by .call is valid here.
209
- #
210
- # agent = SupportAgent.new(input: "hello", language: :ru)
211
- # result = agent.call # use stored params
212
- # result = agent.call(constraints: { max_input_length: 200 }) # merge overrides
213
- def initialize(input: nil, context: {}, constraints: {}, language: nil,
214
- translate: nil, options: {})
215
- @stored_params = {
216
- input: input,
217
- context: context,
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
- # Execute the agent. +overrides+ is merged into the stored params
226
- # any key present in overrides wins.
227
- def call(**overrides)
228
- params = @stored_params.merge(overrides) do |_key, stored, override|
229
- # For Hash values (context, constraints) do a shallow merge so callers
230
- # can add keys without replacing the whole hash.
231
- (stored.is_a?(Hash) && override.is_a?(Hash)) ? stored.merge(override) : override
232
- end
233
- self.class.call(**params)
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 use(provider:, model:)
246
- @config[:use] = { provider: provider, model: model }
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 fallback(provider:, model:)
250
- @config[:fallbacks] << { provider: provider, model: model }
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
- def to_h
254
- @config
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
- # Base
4
- class Error < StandardError; end
3
+ Error = Class.new(StandardError)
5
4
 
6
- # Configuration
7
- class ConfigurationError < Error; end
8
- class ContextValidationError < Error; end
5
+ # Raised when all models in the chain fail
6
+ AllModelsFailed = Class.new(Error)
9
7
 
10
- # Provider retryable (fallback chain continues)
11
- class ProviderError < Error; end
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
- # Providerterminal (fallback chain stops)
18
- class InvalidRequestError < ProviderError; end
19
- class InvalidApiKeyError < ProviderError; end
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
- # Output
23
- class SchemaValidationError < Error; end
24
- class OutputParsingError < Error; end
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
- # Guard — raised when the guard model returns unparseable or schema-invalid JSON.
27
- # Triggers the retry loop inside GuardRunner.
28
- class GuardResponseError < Error; end
22
+ TimeoutError = Class.new(ProviderError)
23
+ RateLimitError = Class.new(ProviderError)
24
+ ServerError = Class.new(ProviderError)
25
+ ProviderUnavailableError = Class.new(ProviderError)
29
26
 
30
- # Input constraints (max_input_length, etc.)
31
- class ConstraintViolationError < Error; end
32
-
33
- # Throttling (application-level, not provider-level)
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 POST adapter backed by Net::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 request body
21
- # @param timeout [Integer] seconds for both open and read timeout
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 = Net::HTTP::Post.new(url)
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::ReadTimeout, Net::OpenTimeout
35
- raise Errors::TimeoutError, "Request timed out (#{url.host})"
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