active_harness 0.2.25 → 0.2.27

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2a3e44b6a9f63f595ee483620a0aac1080825ce3f1d585696930db1be0991f7
4
- data.tar.gz: 28382baad847f75cfdc4613c143b325d9cd4b8b31913df2812e76ff33dbd80bf
3
+ metadata.gz: 5b406bc5feeebdb4cc26e215a65d25dd931605684c6f05ee7cdd71de5910a1d6
4
+ data.tar.gz: a34d2e22840ae906d47b0e27fe271d97b42d97a0fce64a19a58e15c296f00bf7
5
5
  SHA512:
6
- metadata.gz: f0b0422ecb620e3d9749133d17dcfd66b0328cbaf60ebe17b8c835ceb8be94fdb7c94a3b980afa85a4251ba4b42acae05bb9ad5250c8ab3c9def0e92e9094ff6
7
- data.tar.gz: 5e2502fbc8ad9cf559c9cd9aecb8ea8e78abc330f442991e99a21e8cf7a3b1aa4682f3e926b3632157932ee32f1549a3e5861764b4883f7271154d81ad4dbbdb
6
+ metadata.gz: f53ee25213ac7404840349cee732aa59b1dc25168b4e362028e7542fd51a0c51e9f2a2af4b8cbad9a552e74a9c4769d147b7d642e3b051df9c293af58bc87f34
7
+ data.tar.gz: d56efdd52b38afd9153903611fd860e316a59eb8f5d5b5c3ed0510b22647b8008a3b22f4e0c8430322548bb82a50c69cc4d6c086e8d965653c22d795fd43cae7
@@ -14,7 +14,7 @@ module ActiveHarness
14
14
  # Output format for this agent.
15
15
  #
16
16
  # format :text # default — output is returned as-is
17
- # format :json # output is parsed; result.parsed is a Ruby Hash/Array
17
+ # format :json # output is parsed; result.processed is a Ruby Hash/Array
18
18
  def format(type)
19
19
  unless %i[text json].include?(type)
20
20
  raise ArgumentError, "Unknown format :#{type}. Valid values: :text, :json"
@@ -39,7 +39,7 @@ module ActiveHarness
39
39
  begin
40
40
  parsed = JSON.parse(clean)
41
41
 
42
- # :after_parse — return value replaces parsed result stored in Result
42
+ # :after_parse — return value replaces processed result stored in Result
43
43
  transform_hook(:after_parse, parsed)
44
44
  rescue JSON::ParserError => e
45
45
  # :parse_error — if hook returns non-nil, it is used as fallback value
@@ -53,6 +53,7 @@ module ActiveHarness
53
53
  obj.instance_variable_set(:@input, @input)
54
54
  obj.instance_variable_set(:@context, @context)
55
55
  obj.instance_variable_set(:@config, @config)
56
+ obj.instance_variable_set(:@memory, @memory)
56
57
  end
57
58
  end
58
59
  end
@@ -14,6 +14,7 @@ module ActiveHarness
14
14
  input: nil,
15
15
  context: {},
16
16
  params: {},
17
+ memory: nil,
17
18
  models: nil,
18
19
  streams: {}
19
20
  )
@@ -21,6 +22,7 @@ module ActiveHarness
21
22
  input: input,
22
23
  context: context,
23
24
  params: params,
25
+ memory: memory,
24
26
  models: models,
25
27
  streams: streams
26
28
  ).call
@@ -49,7 +51,8 @@ module ActiveHarness
49
51
  # -------------------------------------------------------------------------
50
52
  attr_accessor :input,
51
53
  :context,
52
- :params
54
+ :params,
55
+ :memory
53
56
  attr_reader :result,
54
57
  :token_stream,
55
58
  :event_stream
@@ -63,6 +66,7 @@ module ActiveHarness
63
66
  input: nil,
64
67
  context: {},
65
68
  params: {},
69
+ memory: nil,
66
70
  models: nil,
67
71
  streams: {}
68
72
  )
@@ -71,6 +75,7 @@ module ActiveHarness
71
75
  normalize_input!
72
76
  @context = context
73
77
  @params = params
78
+ @memory = memory
74
79
  @models_override = Array(models) if models
75
80
  @token_stream = streams[:token]
76
81
  @event_stream = streams[:agent]
@@ -140,13 +145,13 @@ module ActiveHarness
140
145
 
141
146
  def build_result(response, entry, attempts, elapsed)
142
147
  raw = response[:content]
143
- parsed = parse_output(raw)
148
+ processed = parse_output(raw)
144
149
  usage = response[:usage]
145
150
 
146
151
  Result.new(
147
152
  input: @input,
148
153
  output: raw,
149
- parsed: parsed,
154
+ processed: processed,
150
155
  system_prompt: @system_prompt,
151
156
  provider: entry[:provider],
152
157
  model: entry[:model],
@@ -0,0 +1,252 @@
1
+ # Pipeline
2
+
3
+ A pipeline chains multiple agents and tribunals into a sequential workflow.
4
+ Each step receives the current payload, can transform it, and can stop the pipeline early.
5
+
6
+ ## Basic usage
7
+
8
+ ```ruby
9
+ class SupportPipeline < ActiveHarness::Pipeline
10
+ step :translate, TranslationAgent
11
+
12
+ step :injection_guard do
13
+ use InjectionGuardAgent
14
+ stop_if ->(result) { result.processed["detected"] == true }
15
+ end
16
+
17
+ step :safety_tribunal do
18
+ use SafetyTribunal
19
+ stop_if ->(result) { result.verdict == false }
20
+ end
21
+ end
22
+
23
+ pipeline = SupportPipeline.new(input: "Hello", context: { user_id: 1 })
24
+ pipeline.call
25
+
26
+ pipeline.output # => final payload string (nil if stopped)
27
+ pipeline.stopped? # => false
28
+ pipeline.step_results # => { translate: <Result>, injection_guard: <Result>, ... }
29
+ ```
30
+
31
+ ## Step types
32
+
33
+ There are two kinds of classes a step can use.
34
+
35
+ **Agent step** — runs the agent, takes `result.output` as the new payload:
36
+
37
+ ```ruby
38
+ step :translate, TranslationAgent
39
+ ```
40
+
41
+ **Tribunal step** — runs the tribunal, returns a `Result` with `processed["verdict"]`.
42
+ Payload is never updated by a tribunal step (it always has `stop_if`):
43
+
44
+ ```ruby
45
+ step :safety_tribunal do
46
+ use SafetyTribunal
47
+ stop_if ->(result) { result.processed["verdict"] == false }
48
+ end
49
+ ```
50
+
51
+ ## Payload propagation
52
+
53
+ The payload starts as the value passed to `input:` and flows through the steps:
54
+
55
+ | Condition | Payload after step |
56
+ |-----------|--------------------|
57
+ | Agent step, no `stop_if` | Updated to `result.output` |
58
+ | Agent step with `stop_if` | Unchanged (guard step) |
59
+ | Tribunal step | Unchanged |
60
+
61
+ After each step the result is also stored in `context[step_name]`,
62
+ so later steps can read earlier results via `@context[:translate]` etc.
63
+
64
+ ## Stopping the pipeline
65
+
66
+ Any step can stop the pipeline by defining `stop_if`:
67
+
68
+ ```ruby
69
+ step :injection_guard do
70
+ use InjectionGuardAgent
71
+ stop_if ->(result) { result.processed["detected"] == true }
72
+ end
73
+ ```
74
+
75
+ When the condition is true:
76
+ - remaining steps are skipped
77
+ - `pipeline.stopped?` returns `true`
78
+ - `pipeline.stopped_at` holds the step name
79
+ - `pipeline.output` is `nil`
80
+
81
+ ## Events and hooks
82
+
83
+ ```ruby
84
+ class SupportPipeline < ActiveHarness::Pipeline
85
+ on_agent_event do |event, result| ... end # fires for every agent inside
86
+ on_tribunal_event do |event, verdict| ... end # fires for every tribunal inside
87
+ on_pipeline_event do |event, *args| ... end # :before_step, :after_step, :stopped, :complete
88
+ end
89
+ ```
90
+
91
+ Runtime streams can be passed at construction time:
92
+
93
+ ```ruby
94
+ SupportPipeline.new(
95
+ input: "...",
96
+ streams: { token: token_lambda, agent: agent_lambda }
97
+ )
98
+ ```
99
+
100
+ ## Memory
101
+
102
+ A memory object can be attached to the pipeline. It is loaded before the first step
103
+ and a record is written after successful completion (skipped if the pipeline stops early):
104
+
105
+ ```ruby
106
+ mem = ActiveHarness::Memory::JsonFile.new(file_name: "session_42")
107
+
108
+ SupportPipeline.new(input: "...", memory: mem).call
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Proposal: universal step interface
114
+
115
+ Currently `Pipeline::Step` special-cases two concrete classes: `Agent` and `Tribunal`.
116
+ This section explores making the pipeline open to any entity — a plain Ruby object,
117
+ a lambda, a nested pipeline, an HTTP call, a cache lookup — with no inheritance required.
118
+
119
+ The core question is: **what must a step return so the pipeline can drive it?**
120
+
121
+ ---
122
+
123
+ ### Option A — Duck-type protocol (minimal change)
124
+
125
+ Define a lightweight protocol. Any object that satisfies it can be a step:
126
+
127
+ ```ruby
128
+ # Contract: class responds to .new(input:, context:, params:, streams:)
129
+ # Instance responds to .call → returns an object with:
130
+ # .output — new payload (String or any value); nil keeps payload unchanged
131
+ # .stop? — true signals the pipeline to halt (replaces stop_if in step DSL)
132
+
133
+ class UppercaseStep
134
+ def initialize(input:, **); @input = input; end
135
+
136
+ def call
137
+ Pipeline::StepResult.new(output: @input.upcase)
138
+ end
139
+ end
140
+
141
+ step :upcase, UppercaseStep
142
+ ```
143
+
144
+ `Pipeline::StepResult` would be a tiny value object:
145
+
146
+ ```ruby
147
+ Pipeline::StepResult = Struct.new(:output, :stop, keyword_init: true) do
148
+ def stop? = stop
149
+ end
150
+ ```
151
+
152
+ **Pros:** almost no change to existing code; agents and tribunals get thin adapters.
153
+ **Cons:** every custom step must construct `StepResult`; slightly more boilerplate.
154
+
155
+ ---
156
+
157
+ ### Option B — Callable (lambda / proc) as a step
158
+
159
+ Allow any `Proc`/`lambda` directly, without a wrapper class:
160
+
161
+ ```ruby
162
+ step :sanitize, ->(payload, ctx) { payload.strip }
163
+
164
+ step :length_guard, ->(payload, ctx) {
165
+ payload.length > 1000 ? Pipeline::Stop : payload
166
+ }
167
+ ```
168
+
169
+ Return value rules:
170
+ - any value other than `Pipeline::Stop` → becomes new payload
171
+ - `Pipeline::Stop` (or `Pipeline::Stop.new(reason)`) → halts the pipeline
172
+
173
+ **Pros:** perfect for simple transformations and guards; zero boilerplate.
174
+ **Cons:** no access to `params:` or `streams:` without enlarging the lambda signature;
175
+ harder to test in isolation.
176
+
177
+ ---
178
+
179
+ ### Option C — Rack-style env hash
180
+
181
+ Each step receives and returns a single hash (`env`), similar to Rack middleware:
182
+
183
+ ```ruby
184
+ # env keys: :input, :output, :context, :params, :streams
185
+ # Return env to continue, return Pipeline::Stop to halt.
186
+
187
+ class UppercaseStep
188
+ def call(env)
189
+ env.merge(output: env[:input].upcase)
190
+ end
191
+ end
192
+
193
+ class LengthGuard
194
+ def call(env)
195
+ env[:input].length > 1000 ? Pipeline::Stop.new("too long") : env
196
+ end
197
+ end
198
+ ```
199
+
200
+ Steps become stateless (no `initialize`) — a single instance can be reused:
201
+
202
+ ```ruby
203
+ UPCASE = UppercaseStep.new
204
+
205
+ step :upcase, UPCASE
206
+ step :length_guard, LengthGuard.new
207
+ ```
208
+
209
+ **Pros:** stateless, composable, easy to test (`call(env)` in one line); nested
210
+ pipelines become trivial — a pipeline is just another object with `call(env)`.
211
+ **Cons:** largest departure from the current API; requires migrating Agent/Tribunal wrappers.
212
+
213
+ ---
214
+
215
+ ### Option D — `Pipeline::Callable` module (explicit contract)
216
+
217
+ A module that documents the contract and provides `StepResult` helpers:
218
+
219
+ ```ruby
220
+ class EnrichStep
221
+ include ActiveHarness::Pipeline::Callable # documents intent, no magic
222
+
223
+ def initialize(input:, context:, **); @input = input; @context = context; end
224
+
225
+ def call
226
+ data = ExternalService.fetch(@context[:user_id])
227
+ result(output: "#{@input} [enriched: #{data}]") # helper from Callable
228
+ end
229
+ end
230
+ ```
231
+
232
+ Agents and Tribunals include `Callable` automatically, so they work as before.
233
+ Any plain class can opt in with one `include`.
234
+
235
+ **Pros:** clear opt-in contract; helpers reduce boilerplate; IDE-friendly.
236
+ **Cons:** still requires `include`; doesn't help lambdas or nested pipelines directly.
237
+
238
+ ---
239
+
240
+ ### Comparison
241
+
242
+ | | No inheritance | Lambda support | Nested pipeline | Migration cost |
243
+ |---|---|---|---|---|
244
+ | **A — duck type** | yes | with wrapper | yes | low |
245
+ | **B — lambda** | yes | native | no | low |
246
+ | **C — Rack env** | yes | yes (`.call`) | yes (trivially) | high |
247
+ | **D — module** | yes (opt-in) | with wrapper | yes | low |
248
+
249
+ **Recommendation:** start with **B** (lambda steps) for simple cases and **A** (duck-type
250
+ protocol + `StepResult`) for structured steps — both require minimal changes to the
251
+ existing engine. Option C is the most powerful but is a bigger refactor; consider it
252
+ if nested pipelines or stateless reuse become a real need.
@@ -7,7 +7,7 @@ module ActiveHarness
7
7
  # class SupportPipeline < ActiveHarness::Pipeline
8
8
  # step :injection_guard do
9
9
  # use InjectionGuardAgent
10
- # stop_if ->(result) { result.parsed["detected"] == true }
10
+ # stop_if ->(result) { result.processed["detected"] == true }
11
11
  # end
12
12
  #
13
13
  # step :translate, TranslationAgent # shorthand — no stop_if
@@ -44,7 +44,7 @@ module ActiveHarness
44
44
  # Full block form:
45
45
  # step :injection_guard do
46
46
  # use InjectionGuardAgent
47
- # stop_if ->(result) { result.parsed["detected"] == true }
47
+ # stop_if ->(result) { result.processed["detected"] == true }
48
48
  # end
49
49
  def step(name, agent_class = nil, &block)
50
50
  pipeline_config[:steps] << Pipeline::Step.new(name, agent_class, &block)
@@ -210,23 +210,13 @@ module ActiveHarness
210
210
  end
211
211
 
212
212
  def execute_step(step)
213
- if step.tribunal?
214
- agent_streams = { token: @token_stream, agent: @agent_event_stream, tribunal: @tribunal_event_stream }.compact
215
- step.agent_class.new(
216
- input: @payload,
217
- context: @context.dup,
218
- params: @params,
219
- streams: agent_streams
220
- ).call
221
- else
222
- agent_streams = { token: @token_stream, agent: @agent_event_stream }.compact
223
- step.agent_class.new(
224
- input: @payload,
225
- context: @context.dup,
226
- params: @params,
227
- streams: agent_streams
228
- ).call.result
229
- end
213
+ streams = { token: @token_stream, agent: @agent_event_stream, tribunal: @tribunal_event_stream }.compact
214
+ step.agent_class.new(
215
+ input: @payload,
216
+ context: @context.dup,
217
+ params: @params,
218
+ streams: streams
219
+ ).call.result
230
220
  end
231
221
  end
232
222
  end
@@ -25,7 +25,14 @@ module ActiveHarness
25
25
  "anthropic-version" => ANTHROPIC_VERSION
26
26
  }
27
27
 
28
- return call_streaming(url: config.anthropic_api_url, headers: headers, body: body, stream: stream, provider: :anthropic, model: model) if stream
28
+ return call_streaming(
29
+ url: config.anthropic_api_url,
30
+ headers: headers,
31
+ body: body,
32
+ stream: stream,
33
+ provider: :anthropic,
34
+ model: model
35
+ ) if stream
29
36
 
30
37
  raw = post_json(URI(config.anthropic_api_url), headers: headers, body: body)
31
38
  data = parse!(raw)
@@ -4,8 +4,20 @@ module ActiveHarness
4
4
  # Minimal result wrapper returned by Agent#call.
5
5
  #
6
6
  # output — raw string from the provider
7
- # parsed — for format :json: a Ruby Hash/Array; for format :text: same as output
7
+ # processed — for format :json: a Ruby Hash/Array; for format :text: same as output
8
8
  # usage — token counts: { input_tokens:, output_tokens:, total_tokens: } or nil for streaming
9
9
  # cost — { input_cost:, output_cost:, total_cost: } in USD, or nil if pricing unavailable
10
- Result = Struct.new(:input, :output, :parsed, :system_prompt, :provider, :model, :temperature, :model_list, :attempts, :execution_time, :usage, :cost, keyword_init: true)
10
+ Result = Struct.new(
11
+ :input,
12
+ :output,
13
+ :processed,
14
+ :system_prompt,
15
+ :provider, :model,
16
+ :temperature,
17
+ :model_list,
18
+ :attempts,
19
+ :execution_time,
20
+ :usage,
21
+ :cost,
22
+ keyword_init: true)
11
23
  end
@@ -13,7 +13,7 @@ module ActiveHarness
13
13
  # Receives the full results array; return value becomes #verdict.
14
14
  # Takes priority over +verdict+ strategy if both are declared.
15
15
  #
16
- # process { |results| results.all? { |r| r.parsed["result"] == true } }
16
+ # process { |results| results.all? { |r| r.processed["result"] == true } }
17
17
  def process(&block)
18
18
  tribunal_config[:process] = block
19
19
  end
@@ -31,11 +31,11 @@ module ActiveHarness
31
31
  # The block receives a single Result and must return a truthy/falsy value.
32
32
  #
33
33
  # verdict :unanimous do |result|
34
- # result.parsed["result"] == true
34
+ # result.processed["result"] == true
35
35
  # end
36
36
  #
37
37
  # verdict :majority, may_fail: 1 do |result|
38
- # result.parsed["result"] == true
38
+ # result.processed["result"] == true
39
39
  # end
40
40
  VALID_STRATEGIES = %i[unanimous majority].freeze
41
41
 
@@ -2,7 +2,7 @@ module ActiveHarness
2
2
  class Tribunal
3
3
  # Instance-level process block — overrides class-level block.
4
4
  #
5
- # tribunal.process { |results| results.count { |r| r.parsed["ok"] } >= 2 }
5
+ # tribunal.process { |results| results.count { |r| r.processed["ok"] } >= 2 }
6
6
  def process(&block)
7
7
  @process_block = block
8
8
  self
@@ -17,14 +17,14 @@ module ActiveHarness
17
17
  # timeout: 7
18
18
  # )
19
19
  # tribunal.on(:after_agent) { |result| puts result.model }
20
- # tribunal.process { |results| results.all? { |r| r.parsed["result"] == true } }
20
+ # tribunal.process { |results| results.all? { |r| r.processed["result"] == true } }
21
21
  # tribunal.call
22
22
  #
23
23
  # Subclass with DSL:
24
24
  # class ContentQualityTribunal < ActiveHarness::Tribunal
25
25
  # agents PolitenessAgent, ConstructivenessAgent
26
26
  # on(:after_agent) { |result| puts result.model }
27
- # process { |results| results.all? { |r| r.parsed["result"] == true } }
27
+ # process { |results| results.all? { |r| r.processed["result"] == true } }
28
28
  # end
29
29
  # ContentQualityTribunal.new(input: "...").call
30
30
  #
@@ -48,7 +48,8 @@ module ActiveHarness
48
48
  # -------------------------------------------------------------------------
49
49
  attr_accessor :input,
50
50
  :context,
51
- :params
51
+ :params,
52
+ :memory
52
53
  attr_reader :results,
53
54
  :errors,
54
55
  :verdict,
@@ -62,6 +63,7 @@ module ActiveHarness
62
63
  input: nil,
63
64
  context: {},
64
65
  params: {},
66
+ memory: nil,
65
67
  agents: nil,
66
68
  timeout: 7,
67
69
  streams: {},
@@ -72,6 +74,7 @@ module ActiveHarness
72
74
  @input = input
73
75
  @context = context
74
76
  @params = params
77
+ @memory = memory
75
78
  @agents = agents || config[:agents]
76
79
  @timeout = timeout
77
80
  @process_block = config[:process]
@@ -89,6 +92,17 @@ module ActiveHarness
89
92
  @agent_execution_times = []
90
93
  end
91
94
 
95
+ # Returns a Result with processed: { "verdict" => @verdict } so the pipeline
96
+ # can handle agents and tribunals through the same interface.
97
+ def result
98
+ Result.new(
99
+ input: @input,
100
+ output: nil,
101
+ processed: { "verdict" => @verdict },
102
+ execution_time: @execution_time
103
+ )
104
+ end
105
+
92
106
  # Run all agents in parallel, then compute the verdict.
93
107
  # Returns self so calls can be chained: tribunal.call.verdict
94
108
  #
@@ -30,7 +30,7 @@ require_relative "active_harness/pipeline"
30
30
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
31
31
 
32
32
  module ActiveHarness
33
- VERSION = "0.2.25"
33
+ VERSION = "0.2.27"
34
34
 
35
35
  class << self
36
36
  # Configure ActiveHarness.
@@ -6,6 +6,6 @@ class SupportGuardTribunal < ActiveHarness::Tribunal
6
6
  agents SupportGuardAgent
7
7
 
8
8
  process do |results|
9
- results.none? { |r| r.parsed["spam"] == true }
9
+ results.none? { |r| r.processed["spam"] == true }
10
10
  end
11
11
  end
@@ -2,6 +2,6 @@ class <%= class_name %>Tribunal < ActiveHarness::Tribunal
2
2
  agents <%= class_name %>Agent
3
3
 
4
4
  process do |results|
5
- results.all? { |r| r.parsed["result"] == true }
5
+ results.all? { |r| r.processed["result"] == true }
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.25
4
+ version: 0.2.27
5
5
  platform: ruby
6
6
  authors:
7
7
  - the-teacher
@@ -54,6 +54,7 @@ files:
54
54
  - lib/active_harness/memory/adapter/postgresql.rb
55
55
  - lib/active_harness/memory/adapter/sqlite.rb
56
56
  - lib/active_harness/pipeline.rb
57
+ - lib/active_harness/pipeline/README.md
57
58
  - lib/active_harness/pipeline/hooks.rb
58
59
  - lib/active_harness/pipeline/step.rb
59
60
  - lib/active_harness/providers/PROVIDER_CONTRACT.md