active_harness 0.2.25 → 0.2.26

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: e251d772ac23a015473080cd54ec79dcd1d707b3ca9fa1c8de6132f8152fa873
4
+ data.tar.gz: 68674b1744f7696cb26cc02a505e60e8c6ae117c7f20a118a2e8102c8f0821f5
5
5
  SHA512:
6
- metadata.gz: f0b0422ecb620e3d9749133d17dcfd66b0328cbaf60ebe17b8c835ceb8be94fdb7c94a3b980afa85a4251ba4b42acae05bb9ad5250c8ab3c9def0e92e9094ff6
7
- data.tar.gz: 5e2502fbc8ad9cf559c9cd9aecb8ea8e78abc330f442991e99a21e8cf7a3b1aa4682f3e926b3632157932ee32f1549a3e5861764b4883f7271154d81ad4dbbdb
6
+ metadata.gz: 99ef61764b9c6b7564f1064c191cb89780670c7e24966556f3f52e3fa15b631e492c9c29a3ac7f601249db9515afed291fe2e6d6e333f9fb6c2043dbad22dac1
7
+ data.tar.gz: 8c8609086bc572183398cf20ff0ea2957e8acd8337db9dadfcb8045f1bbdbb7a708b3f3b27273809a717e91196abe4bf272778d521bfa0926338f0df03486e84
@@ -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
@@ -140,13 +140,13 @@ module ActiveHarness
140
140
 
141
141
  def build_result(response, entry, attempts, elapsed)
142
142
  raw = response[:content]
143
- parsed = parse_output(raw)
143
+ processed = parse_output(raw)
144
144
  usage = response[:usage]
145
145
 
146
146
  Result.new(
147
147
  input: @input,
148
148
  output: raw,
149
- parsed: parsed,
149
+ processed: processed,
150
150
  system_prompt: @system_prompt,
151
151
  provider: entry[:provider],
152
152
  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
@@ -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
  #
@@ -89,6 +89,17 @@ module ActiveHarness
89
89
  @agent_execution_times = []
90
90
  end
91
91
 
92
+ # Returns a Result with processed: { "verdict" => @verdict } so the pipeline
93
+ # can handle agents and tribunals through the same interface.
94
+ def result
95
+ Result.new(
96
+ input: @input,
97
+ output: nil,
98
+ processed: { "verdict" => @verdict },
99
+ execution_time: @execution_time
100
+ )
101
+ end
102
+
92
103
  # Run all agents in parallel, then compute the verdict.
93
104
  # Returns self so calls can be chained: tribunal.call.verdict
94
105
  #
@@ -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.26"
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.26
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