active_harness 0.2.21 → 0.2.23

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: de49ac66334a5feb91a5d5f0314ab2b9b3460a5d3a549f08b77538677eae619b
4
- data.tar.gz: 0bffb6e77d1b8def25121b144dbc956c2c1b0c684826910e3dd0a66ab595a73e
3
+ metadata.gz: 0bf8ac57d79582b07a2d82b29be4541fcaf10b3ee8492d362d6ac10f2843537d
4
+ data.tar.gz: cabffcc74dd594c5222a8e0233aa9d5f7276d65cfa06848d3e2684e19efe393e
5
5
  SHA512:
6
- metadata.gz: e51c5ab8c3911d2597f6269f695c2f4992f960d771b4284fdd27121939aca14dfea53e568ecbfc069af26b405725106c18b3beebf0232e751ed8144b3134cea7
7
- data.tar.gz: ea95a9f48993b4dc930addeee4260e6056815ec48d3c2534a6bf6f5001168cad2104fcd988c98e442f940f32faa4161f9b59a4692bc6fa3adf7655a221d5d56b
6
+ metadata.gz: '018677f5498399d22c2a1b497649e4676023eae7b2578b8b0c13962b1c443970f20e2af5e7ca15051f75d2903287ed9f12012407cf9a671ad1d5d688d8def153'
7
+ data.tar.gz: 5219e6c2a208ff1a2215a71d924c24ade46b6abebc94e9a21c7a36d931d9e32919e7ddc5b054ff7bb616b46057761138aa1a01ce21e66ef6978bb5802304cabe
@@ -32,7 +32,7 @@ module ActiveHarness
32
32
  end
33
33
 
34
34
  agent_config[:hooks] ||= {}
35
- agent_config[:hooks][event] = block
35
+ (agent_config[:hooks][event] ||= []) << block
36
36
  end
37
37
 
38
38
  # Rails-style aliases for +on+:
@@ -59,27 +59,20 @@ module ActiveHarness
59
59
  end
60
60
  end
61
61
 
62
+ include Core::HookRunner
63
+
62
64
  private
63
65
 
64
66
  def run_hook(event, *args)
65
- hooks = @config[:hooks] || {}
66
- return unless hooks[event]
67
-
68
- if args.any?
69
- instance_exec(*args, &hooks[event])
70
- else
71
- instance_eval(&hooks[event])
72
- end
67
+ run_hooks(@config[:hooks] || {}, event, *args)
73
68
  end
74
69
 
75
70
  # Unified internal method: fires the DSL hook AND the external event_stream lambda.
76
71
  # Consistent with Tribunal#fire and Pipeline#fire.
77
72
  def fire(event, *args)
78
- result = run_hook(event, *args)
73
+ run_hook(event, *args)
79
74
  @event_stream&.call(event, *args)
80
- result
81
75
  rescue IOError, ActionController::Live::ClientDisconnected
82
- result
83
76
  end
84
77
  end
85
78
  end
@@ -10,9 +10,20 @@ module ActiveHarness
10
10
  #
11
11
  # SupportAgent.call(input: "Hi")
12
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, streams: {})
15
- new(input: input, context: context, models: models, memory: memory, streams: streams).call
13
+ def call(
14
+ input: nil,
15
+ context: {},
16
+ params: {},
17
+ models: nil,
18
+ streams: {}
19
+ )
20
+ new(
21
+ input: input,
22
+ context: context,
23
+ params: params,
24
+ models: models,
25
+ streams: streams
26
+ ).call
16
27
  end
17
28
 
18
29
  # Each subclass gets its own isolated config hash.
@@ -36,28 +47,33 @@ module ActiveHarness
36
47
  # -------------------------------------------------------------------------
37
48
  # Instance API
38
49
  # -------------------------------------------------------------------------
39
- attr_accessor :input, :context
40
- attr_reader :result, :token_stream, :event_stream
50
+ attr_accessor :input,
51
+ :context,
52
+ :params
53
+ attr_reader :result,
54
+ :token_stream,
55
+ :event_stream
41
56
 
42
57
  def models=(list)
43
58
  @models_override = Array(list)
44
59
  @model_list_proxy = nil
45
60
  end
46
61
 
47
- def memory=(obj)
48
- @memory = obj
49
- end
50
-
51
- def initialize(input: nil, context: {}, models: nil, memory: nil, streams: {})
62
+ def initialize(
63
+ input: nil,
64
+ context: {},
65
+ params: {},
66
+ models: nil,
67
+ streams: {}
68
+ )
52
69
  @input = input
53
70
  @config = self.class.agent_config
54
71
  normalize_input!
55
72
  @context = context
73
+ @params = params
56
74
  @models_override = Array(models) if models
57
75
  @token_stream = streams[:token]
58
76
  @event_stream = streams[:agent]
59
- # memory: can be passed directly or via context[:memory]
60
- @memory = memory || @context[:memory]
61
77
  fire(:setup)
62
78
  end
63
79
 
@@ -76,9 +92,8 @@ module ActiveHarness
76
92
  @token_stream = streams[:token] if streams.key?(:token)
77
93
  @event_stream = streams[:agent] if streams.key?(:agent)
78
94
  end
79
- @memory&.load
80
- @system_prompt = resolve_system_prompt
81
95
  fire(:before_call)
96
+ @system_prompt = resolve_system_prompt
82
97
  attempts = []
83
98
 
84
99
  cfg = ActiveHarness.config
@@ -92,7 +107,6 @@ module ActiveHarness
92
107
  response = retry_policy.run { attempt_model(entry, @system_prompt) }
93
108
  elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0).round(3)
94
109
  result = build_result(response, entry, attempts, elapsed)
95
- save_to_memory(result)
96
110
  fire(:after_call, result)
97
111
  @result = result
98
112
  return self
@@ -145,21 +159,6 @@ module ActiveHarness
145
159
  )
146
160
  end
147
161
 
148
- # Auto-save to memory if no manual record was done in after_call hook.
149
- # Hooks fire after this method — if a hook calls memory.record manually,
150
- # the automatic save here is still the first save (hook overrides are additive).
151
- # To suppress auto-save, set @memory_auto_saved in the hook.
152
- def save_to_memory(result)
153
- return unless @memory
154
-
155
- @memory.record(
156
- request: @input,
157
- response: result.output,
158
- agent: self.class.name,
159
- model: result.model
160
- )
161
- end
162
-
163
162
  def normalize_input!
164
163
  return if @config.fetch(:normalize_input, true) == false
165
164
  @input = @input&.strip&.gsub(/\s+/, " ")
@@ -0,0 +1,26 @@
1
+ module ActiveHarness
2
+ module Core
3
+ # Shared hook execution logic included by Agent, Tribunal, and Pipeline.
4
+ #
5
+ # Hooks are stored in arrays so multiple +on+/+before+/+after+/+callback+
6
+ # calls with the same event name accumulate — later registrations append
7
+ # rather than overwrite. This lets modules register default hooks without
8
+ # blocking user-defined hooks on the same event.
9
+ #
10
+ # class MyAgent < ActiveHarness::Agent
11
+ # include SomeTracingConcern # registers before(:call) internally
12
+ # before(:call) { ... } # appends — both hooks run in order
13
+ # end
14
+ module HookRunner
15
+ private
16
+
17
+ # Execute every block registered for +event+, passing +args+ to each.
18
+ # Blocks run in the receiver's instance context (instance_exec / instance_eval).
19
+ def run_hooks(hooks_hash, event, *args)
20
+ Array(hooks_hash[event]).each do |blk|
21
+ args.any? ? instance_exec(*args, &blk) : instance_eval(&blk)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -22,13 +22,13 @@ module ActiveHarness
22
22
  "Per-step hooks support: #{VALID_STEP_HOOKS.join(", ")}. Got :#{event}"
23
23
  end
24
24
  pipeline_config[:step_hooks][step_name] ||= {}
25
- pipeline_config[:step_hooks][step_name][event] = block
25
+ (pipeline_config[:step_hooks][step_name][event] ||= []) << block
26
26
  else
27
27
  unless VALID_HOOKS.include?(event)
28
28
  raise ArgumentError,
29
29
  "Unknown Pipeline hook :#{event}. Valid: #{VALID_HOOKS.join(", ")}"
30
30
  end
31
- pipeline_config[:hooks][event] = block
31
+ (pipeline_config[:hooks][event] ||= []) << block
32
32
  end
33
33
  end
34
34
 
@@ -56,12 +56,13 @@ module ActiveHarness
56
56
  end
57
57
  end
58
58
 
59
+ include Core::HookRunner
60
+
59
61
  private
60
62
 
61
63
  # Fires global hook AND pipeline_event_stream. Consistent with Agent#fire and Tribunal#fire.
62
64
  def fire(event, step_name, data, config)
63
- blk = config[:hooks][event]
64
- instance_exec(step_name, data, &blk) if blk
65
+ run_hooks(config[:hooks], event, step_name, data)
65
66
  @pipeline_event_stream&.call(event, step_name, data)
66
67
  rescue IOError, ActionController::Live::ClientDisconnected
67
68
  nil
@@ -70,8 +71,7 @@ module ActiveHarness
70
71
  # Per-step hook: receives (data) only — not forwarded to pipeline_event_stream
71
72
  # (global fire already covers the step event with step_name context).
72
73
  def fire_step(event, step_name, data, config)
73
- blk = config[:step_hooks][step_name]&.dig(event)
74
- instance_exec(data, &blk) if blk
74
+ run_hooks(config[:step_hooks][step_name] || {}, event, data)
75
75
  end
76
76
  end
77
77
  end
@@ -51,39 +51,83 @@ module ActiveHarness
51
51
  end
52
52
 
53
53
  def pipeline_config
54
- @pipeline_config ||= { steps: [], hooks: {}, step_hooks: {} }
54
+ @pipeline_config ||= { steps: [], hooks: {}, step_hooks: {}, streams: {} }
55
55
  end
56
56
 
57
57
  # Each subclass gets its own isolated config.
58
58
  def inherited(subclass)
59
59
  subclass.instance_variable_set(
60
60
  :@pipeline_config,
61
- { steps: [], hooks: {}, step_hooks: {} }
61
+ { steps: [], hooks: {}, step_hooks: {}, streams: {} }
62
62
  )
63
63
  end
64
+
65
+ # Class-level event stream handlers — fired for every matching event from
66
+ # any agent or tribunal executed within this pipeline (including agents
67
+ # running inside tribunals). Multiple blocks can be registered; all fire.
68
+ #
69
+ # The handler receives the same (event, *args) signature that the runtime
70
+ # streams: { agent: lambda } would receive.
71
+ #
72
+ # on_agent_event do |event, result|
73
+ # Rails.logger.info "[Agent #{event}] #{result.model}" if event == :after_call
74
+ # end
75
+ #
76
+ # on_tribunal_event do |event, verdict|
77
+ # Rails.logger.info "[Tribunal #{event}] verdict=#{verdict}" if event == :after_verdict
78
+ # end
79
+ #
80
+ # on_pipeline_event do |event, step_name, _data|
81
+ # Rails.logger.info "[Pipeline #{event}] step=#{step_name}"
82
+ # end
83
+ def on_agent_event(&block)
84
+ (pipeline_config[:streams][:agent] ||= []) << block
85
+ end
86
+
87
+ def on_tribunal_event(&block)
88
+ (pipeline_config[:streams][:tribunal] ||= []) << block
89
+ end
90
+
91
+ def on_pipeline_event(&block)
92
+ (pipeline_config[:streams][:pipeline] ||= []) << block
93
+ end
64
94
  end
65
95
 
66
96
  # -------------------------------------------------------------------------
67
97
  # Instance API
68
98
  # -------------------------------------------------------------------------
69
- attr_reader :original_input, :output, :stopped_at, :stop_reason,
70
- :execution_time, :step_results, :context
71
- attr_writer :context
99
+ attr_reader :original_input,
100
+ :output,
101
+ :stopped_at,
102
+ :stop_reason,
103
+ :execution_time,
104
+ :step_results,
105
+ :context
106
+ attr_writer :context
107
+ attr_accessor :params
72
108
 
73
109
  def input=(value)
74
110
  @original_input = value
75
111
  @payload = value
76
112
  end
77
113
 
78
- def initialize(input:, context: {}, memory: nil, streams: {})
114
+ def initialize(
115
+ input:,
116
+ context: {},
117
+ params: {},
118
+ memory: nil,
119
+ streams: {}
120
+ )
79
121
  @original_input = input
80
122
  @payload = input
81
123
  @context = context.dup
124
+ @params = params
82
125
  @memory = memory
83
126
  @token_stream = streams[:token]
84
- @agent_event_stream = streams[:agent]
85
- @tribunal_event_stream = streams[:tribunal]
86
- @pipeline_event_stream = streams[:pipeline]
127
+ class_streams = self.class.pipeline_config[:streams] || {}
128
+ @agent_event_stream = merge_stream(streams[:agent], class_streams[:agent])
129
+ @tribunal_event_stream = merge_stream(streams[:tribunal], class_streams[:tribunal])
130
+ @pipeline_event_stream = merge_stream(streams[:pipeline], class_streams[:pipeline])
87
131
  @step_results = {}
88
132
  @stopped = false
89
133
  @stopped_at = nil
@@ -120,8 +164,7 @@ module ActiveHarness
120
164
  @stopped = true
121
165
  @stopped_at = step.name
122
166
  @stop_reason = result
123
- blk = config[:hooks][:stopped]
124
- instance_exec(step.name, result, &blk) if blk
167
+ run_hooks(config[:hooks], :stopped, step.name, result)
125
168
  @pipeline_event_stream&.call(:stopped, step.name, result)
126
169
  break
127
170
  end
@@ -138,8 +181,7 @@ module ActiveHarness
138
181
  )
139
182
 
140
183
  last_result = @step_results[@step_results.keys.last]
141
- blk = config[:hooks][:complete]
142
- instance_exec(last_result, &blk) if blk
184
+ run_hooks(config[:hooks], :complete, last_result)
143
185
  @pipeline_event_stream&.call(:complete, last_result)
144
186
  end
145
187
 
@@ -148,12 +190,32 @@ module ActiveHarness
148
190
 
149
191
  private
150
192
 
193
+ # Combines a runtime-passed stream lambda with zero or more class-level handler
194
+ # blocks registered via on_agent_event / on_tribunal_event / on_pipeline_event.
195
+ # Returns nil when there are no handlers at all, preserving the existing
196
+ # "no stream" fast path in agents and tribunals.
197
+ #
198
+ # Each class-level handler is evaluated via instance_exec so that blocks
199
+ # written in the pipeline class body can access pipeline instance variables
200
+ # (e.g. @otel_pipeline_span, @params) and call pipeline instance methods.
201
+ def merge_stream(passed_in, class_handlers)
202
+ class_handlers = Array(class_handlers).compact
203
+ return passed_in if class_handlers.empty?
204
+
205
+ pipeline_instance = self
206
+ ->(event, *args) {
207
+ class_handlers.each { |h| pipeline_instance.instance_exec(event, *args, &h) }
208
+ passed_in&.call(event, *args)
209
+ }
210
+ end
211
+
151
212
  def execute_step(step)
152
213
  if step.tribunal?
153
214
  agent_streams = { token: @token_stream, agent: @agent_event_stream, tribunal: @tribunal_event_stream }.compact
154
215
  step.agent_class.new(
155
216
  input: @payload,
156
217
  context: @context.dup,
218
+ params: @params,
157
219
  streams: agent_streams
158
220
  ).call
159
221
  else
@@ -161,6 +223,7 @@ module ActiveHarness
161
223
  step.agent_class.new(
162
224
  input: @payload,
163
225
  context: @context.dup,
226
+ params: @params,
164
227
  streams: agent_streams
165
228
  ).call.result
166
229
  end
@@ -26,7 +26,7 @@ module ActiveHarness
26
26
  "Unknown Tribunal hook :#{event}. Valid hooks: #{VALID_HOOKS.map { |h| ":#{h}" }.join(", ")}"
27
27
  end
28
28
 
29
- tribunal_config[:hooks][event] = block
29
+ (tribunal_config[:hooks][event] ||= []) << block
30
30
  end
31
31
 
32
32
  # Rails-style aliases for +on+:
@@ -51,7 +51,7 @@ module ActiveHarness
51
51
  end
52
52
  end
53
53
 
54
- # Instance-level hook registration — overrides class-level hooks for this instance.
54
+ # Instance-level hook registration — appends to class-level hooks for this event.
55
55
  # :before_verdict is a transform hook: its return value replaces the results array
56
56
  # passed to the process block.
57
57
  def on(event, &block)
@@ -60,20 +60,16 @@ module ActiveHarness
60
60
  "Unknown Tribunal hook :#{event}. Valid hooks: #{VALID_HOOKS.map { |h| ":#{h}" }.join(", ")}"
61
61
  end
62
62
 
63
- @hooks[event] = block
63
+ (@hooks[event] ||= []) << block
64
64
  self
65
65
  end
66
66
 
67
+ include Core::HookRunner
68
+
67
69
  private
68
70
 
69
71
  def run_hook(event, *args)
70
- return unless @hooks[event]
71
-
72
- if args.any?
73
- instance_exec(*args, &@hooks[event])
74
- else
75
- instance_eval(&@hooks[event])
76
- end
72
+ run_hooks(@hooks, event, *args)
77
73
  end
78
74
 
79
75
  # Fire the DSL-registered hook AND the external tribunal_event_stream lambda (if set).
@@ -84,12 +80,10 @@ module ActiveHarness
84
80
  nil
85
81
  end
86
82
 
87
- # Like run_hook but uses the return value to replace the passed value.
83
+ # Like run_hook but chains all blocks, passing each return value to the next.
88
84
  # Used by :before_verdict to allow results transformation before verdict computation.
89
85
  def transform_hook(event, value)
90
- return value unless @hooks[event]
91
-
92
- instance_exec(value, &@hooks[event])
86
+ Array(@hooks[event]).reduce(value) { |val, blk| instance_exec(val, &blk) }
93
87
  end
94
88
  end
95
89
  end
@@ -46,24 +46,39 @@ module ActiveHarness
46
46
  # -------------------------------------------------------------------------
47
47
  # Instance API
48
48
  # -------------------------------------------------------------------------
49
- attr_accessor :input, :context
50
- attr_reader :results, :errors, :verdict, :execution_time, :agent_execution_times,
51
- :token_stream, :agent_event_stream, :tribunal_event_stream
52
-
53
- def initialize(input: nil, context: {}, agents: nil, timeout: 7,
54
- streams: {},
55
- may_fail: :_unset)
49
+ attr_accessor :input,
50
+ :context,
51
+ :params
52
+ attr_reader :results,
53
+ :errors,
54
+ :verdict,
55
+ :execution_time,
56
+ :agent_execution_times,
57
+ :token_stream,
58
+ :agent_event_stream,
59
+ :tribunal_event_stream
60
+
61
+ def initialize(
62
+ input: nil,
63
+ context: {},
64
+ params: {},
65
+ agents: nil,
66
+ timeout: 7,
67
+ streams: {},
68
+ may_fail: :_unset
69
+ )
56
70
  config = self.class.tribunal_config
57
71
 
58
72
  @input = input
59
73
  @context = context
74
+ @params = params
60
75
  @agents = agents || config[:agents]
61
76
  @timeout = timeout
62
77
  @process_block = config[:process]
63
78
  @strategy = config[:strategy]
64
79
  @evaluate_block = config[:evaluate_block]
65
80
  @may_fail = may_fail == :_unset ? config[:may_fail] : may_fail
66
- @hooks = config[:hooks].dup
81
+ @hooks = config[:hooks].transform_values { |v| Array(v).dup }
67
82
  @token_stream = streams[:token]
68
83
  @agent_event_stream = streams[:agent]
69
84
  @tribunal_event_stream = streams[:tribunal]
@@ -155,7 +170,7 @@ module ActiveHarness
155
170
  agent_streams = { token: @token_stream, agent: @agent_event_stream }.compact
156
171
  @agents.map do |agent|
157
172
  if agent.is_a?(Class)
158
- agent.new(input: @input, context: @context.dup, streams: agent_streams)
173
+ agent.new(input: @input, context: @context.dup, params: @params, streams: agent_streams)
159
174
  else
160
175
  agent.input = @input if @input
161
176
  agent.instance_variable_set(:@token_stream, @token_stream) if @token_stream
@@ -1,5 +1,6 @@
1
1
  require_relative "active_harness/configuration"
2
2
  require_relative "active_harness/core/errors"
3
+ require_relative "active_harness/core/hooks"
3
4
  require_relative "active_harness/result"
4
5
  require_relative "active_harness/http/client"
5
6
  require_relative "active_harness/http/streaming_client"
@@ -29,7 +30,7 @@ require_relative "active_harness/pipeline"
29
30
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
30
31
 
31
32
  module ActiveHarness
32
- VERSION = "0.2.21"
33
+ VERSION = "0.2.23"
33
34
 
34
35
  class << self
35
36
  # Configure ActiveHarness.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.21
4
+ version: 0.2.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - the-teacher
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-27 00:00:00.000000000 Z
11
+ date: 2026-06-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -42,6 +42,7 @@ files:
42
42
  - lib/active_harness/agent/providers.rb
43
43
  - lib/active_harness/configuration.rb
44
44
  - lib/active_harness/core/errors.rb
45
+ - lib/active_harness/core/hooks.rb
45
46
  - lib/active_harness/costs.rb
46
47
  - lib/active_harness/data/models.json
47
48
  - lib/active_harness/http/client.rb