active_harness 0.3.8 → 0.3.10

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_harness/agent/goal_check.rb +26 -0
  3. data/lib/active_harness/agent/state.rb +90 -0
  4. data/lib/active_harness/agent.rb +734 -0
  5. data/lib/active_harness/core/errors.rb +11 -0
  6. data/lib/active_harness/pipeline/hooks.rb +1 -1
  7. data/lib/active_harness/providers/anthropic.rb +57 -8
  8. data/lib/active_harness/providers/base.rb +6 -0
  9. data/lib/active_harness/providers/deepseek.rb +7 -4
  10. data/lib/active_harness/providers/gpustack.rb +7 -4
  11. data/lib/active_harness/providers/groq.rb +7 -4
  12. data/lib/active_harness/providers/hetzner.rb +7 -4
  13. data/lib/active_harness/providers/mistral.rb +7 -4
  14. data/lib/active_harness/providers/ollama.rb +7 -4
  15. data/lib/active_harness/providers/ollama_cloud.rb +7 -4
  16. data/lib/active_harness/providers/openai.rb +7 -4
  17. data/lib/active_harness/providers/openrouter.rb +7 -4
  18. data/lib/active_harness/providers/tool_calling.rb +95 -0
  19. data/lib/active_harness/providers/xai.rb +7 -4
  20. data/lib/active_harness/railtie.rb +1 -1
  21. data/lib/active_harness/request/hooks.rb +1 -1
  22. data/lib/active_harness/request/providers.rb +15 -0
  23. data/lib/active_harness/request.rb +39 -7
  24. data/lib/active_harness/result.rb +4 -0
  25. data/lib/active_harness/tool/path_policy.rb +28 -0
  26. data/lib/active_harness/tool.rb +342 -0
  27. data/lib/active_harness/tribunal/hooks.rb +1 -1
  28. data/lib/active_harness.rb +4 -1
  29. data/lib/generators/active_harness/agent/agent_generator.rb +16 -0
  30. data/lib/generators/active_harness/agent/templates/agent.rb.tt +14 -0
  31. data/lib/generators/active_harness/goal/goal_generator.rb +16 -0
  32. data/lib/generators/active_harness/goal/templates/goal.rb.tt +14 -0
  33. data/lib/generators/active_harness/install/install_generator.rb +30 -25
  34. data/lib/generators/active_harness/install/templates/agents/files_agent.rb +39 -0
  35. data/lib/generators/active_harness/install/templates/concerns/agent_tracing.rb +36 -0
  36. data/lib/generators/active_harness/install/templates/concerns/pipeline_tracing.rb +28 -0
  37. data/lib/generators/active_harness/install/templates/concerns/request_tracing.rb +44 -0
  38. data/lib/generators/active_harness/install/templates/concerns/tool_tracing.rb +21 -0
  39. data/lib/generators/active_harness/install/templates/concerns/tracing_log.rb +18 -0
  40. data/lib/generators/active_harness/install/templates/concerns/tribunal_tracing.rb +28 -0
  41. data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +23 -2
  42. data/lib/generators/active_harness/install/templates/goals/answer_present_goal.rb +22 -0
  43. data/lib/generators/active_harness/install/templates/pipelines/support_pipeline.rb +8 -18
  44. data/lib/generators/active_harness/install/templates/prompts/files_prompt.rb +9 -0
  45. data/lib/generators/active_harness/install/templates/prompts/support_prompt.rb +3 -0
  46. data/lib/generators/active_harness/install/templates/requests/files_request.rb +18 -0
  47. data/lib/generators/active_harness/install/templates/requests/support_guard_request.rb +12 -0
  48. data/lib/generators/active_harness/install/templates/requests/support_request.rb +6 -0
  49. data/lib/generators/active_harness/install/templates/tools/ls_tool.rb +41 -0
  50. data/lib/generators/active_harness/install/templates/tools/read_file_tool.rb +44 -0
  51. data/lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb +7 -0
  52. data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +5 -0
  53. data/lib/generators/active_harness/request/templates/request.rb.tt +2 -0
  54. data/lib/generators/active_harness/tool/templates/tool.rb.tt +24 -0
  55. data/lib/generators/active_harness/tool/tool_generator.rb +16 -0
  56. data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +3 -0
  57. metadata +31 -7
@@ -17,7 +17,9 @@ module ActiveHarness
17
17
  memory: nil,
18
18
  models: nil,
19
19
  token: nil,
20
- stream: nil
20
+ stream: nil,
21
+ messages: nil,
22
+ tools: nil
21
23
  )
22
24
  new(
23
25
  input: input,
@@ -26,7 +28,9 @@ module ActiveHarness
26
28
  memory: memory,
27
29
  models: models,
28
30
  token: token,
29
- stream: stream
31
+ stream: stream,
32
+ messages: messages,
33
+ tools: tools
30
34
  ).call
31
35
  end
32
36
 
@@ -54,7 +58,9 @@ module ActiveHarness
54
58
  attr_accessor :input,
55
59
  :context,
56
60
  :params,
57
- :memory
61
+ :memory,
62
+ :messages,
63
+ :tools
58
64
  attr_reader :result,
59
65
  :token,
60
66
  :stream
@@ -71,9 +77,13 @@ module ActiveHarness
71
77
  memory: nil,
72
78
  models: nil,
73
79
  token: nil,
74
- stream: nil
80
+ stream: nil,
81
+ messages: nil,
82
+ tools: nil
75
83
  )
76
84
  @input = input
85
+ @messages = messages
86
+ @tools = tools
77
87
  @config = self.class.request_config
78
88
  normalize_input!
79
89
  @context = context
@@ -91,11 +101,18 @@ module ActiveHarness
91
101
  # Optionally accepts input and stream callback inline:
92
102
  # request.call("What is the capital of Japan?")
93
103
  # request.call("...", stream: ->(token) { print token })
94
- def call(input = nil, token: nil, stream: nil)
104
+ #
105
+ # Agent extension: pass +messages:+ (a conversation instead of a single input)
106
+ # and +tools:+ (neutral tool schemas). The model may then answer with
107
+ # +result.tool_calls+; nothing is executed here — running tools is the Agent's job.
108
+ def call(input = nil, token: nil, stream: nil, messages: nil, tools: nil)
95
109
  if input
96
110
  @input = input
97
111
  normalize_input!
98
112
  end
113
+ @messages = messages if messages
114
+ @tools = tools if tools
115
+ @input = last_user_content(@messages) if @input.nil? && @messages
99
116
  @token = token if token
100
117
  @stream = stream if stream
101
118
  fire(:before_call)
@@ -147,7 +164,9 @@ module ActiveHarness
147
164
 
148
165
  def build_result(response, entry, attempts, elapsed)
149
166
  raw = response[:content]
150
- processed = parse_output(raw)
167
+ tool_calls = response[:tool_calls]
168
+ tool_calls = nil if tool_calls && tool_calls.empty?
169
+ processed = tool_calls ? raw : parse_output(raw)
151
170
  raw_usage = response[:usage]
152
171
  model_cost = lookup_model_cost(entry)
153
172
 
@@ -160,10 +179,23 @@ module ActiveHarness
160
179
  model_list: model_list,
161
180
  attempts: attempts,
162
181
  execution_time: elapsed,
163
- usage: build_usage(raw_usage, model_cost)
182
+ usage: build_usage(raw_usage, model_cost),
183
+ tool_calls: tool_calls,
184
+ message: tool_calls || @messages ? assistant_message(raw, tool_calls) : nil
164
185
  )
165
186
  end
166
187
 
188
+ def assistant_message(content, tool_calls)
189
+ msg = { role: :assistant, content: content.to_s }
190
+ msg[:tool_calls] = tool_calls if tool_calls
191
+ msg
192
+ end
193
+
194
+ def last_user_content(messages)
195
+ last = Array(messages).reverse.find { |m| (m[:role] || m["role"]).to_s == "user" }
196
+ last && (last[:content] || last["content"])
197
+ end
198
+
167
199
  def build_model_info(entry, model_cost)
168
200
  pricing = if model_cost&.input_per_million && model_cost&.output_per_million
169
201
  ModelPricing.new(
@@ -49,6 +49,8 @@ module ActiveHarness
49
49
  # result.attempts — Array of failed attempt entries before success
50
50
  # result.execution_time — wall-clock seconds for the successful call
51
51
  # result.usage — UsageInfo (tokens + cost), nil for streaming without usage
52
+ # result.tool_calls — Array of { id:, name:, arguments: } the model asked for (nil when none)
53
+ # result.message — the assistant message in neutral shape, ready to append to a messages list
52
54
  Result = Struct.new(
53
55
  :input,
54
56
  :output,
@@ -59,6 +61,8 @@ module ActiveHarness
59
61
  :attempts,
60
62
  :execution_time,
61
63
  :usage, # UsageInfo or nil
64
+ :tool_calls, # Array<Hash> or nil
65
+ :message, # { role: :assistant, content:, tool_calls: } or nil
62
66
  keyword_init: true
63
67
  )
64
68
  end
@@ -0,0 +1,28 @@
1
+ module ActiveHarness
2
+ class Tool
3
+ # Reusable path checks — implemented once, correctly (symlinks, "..", the
4
+ # trailing-slash prefix bug). The including tool must define ALLOWED_PATHS;
5
+ # a missing constant raises NameError, which fails closed (an :error result).
6
+ #
7
+ # class LsTool < ActiveHarness::Tool
8
+ # include ActiveHarness::Tool::PathPolicy
9
+ # ALLOWED_PATHS = %w[/srv/app/workspace].freeze
10
+ #
11
+ # step(:resolve_path, transform: true) { |input| input.merge(path: safe_path!(input[:path])) }
12
+ # end
13
+ module PathPolicy
14
+ # Returns a canonical absolute path inside ALLOWED_PATHS, or raises Denied.
15
+ def safe_path!(path)
16
+ deny!("path must not contain NUL") if path.to_s.include?("\0")
17
+
18
+ real = File.realpath(File.expand_path(path.to_s)) # follows symlinks
19
+ roots = self.class::ALLOWED_PATHS.map { |r| File.realpath(r) }
20
+ ok = roots.any? { |r| real == r || real.start_with?(r + File::SEPARATOR) }
21
+ deny!("#{path} is outside allowed directories", detail: "resolved to #{real}") unless ok
22
+ real
23
+ rescue Errno::ENOENT
24
+ deny!("#{path} does not exist")
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,342 @@
1
+ require "json"
2
+
3
+ module ActiveHarness
4
+ # One step of a tool call, as recorded in ToolResult#steps.
5
+ #
6
+ # name — step name (:perform for the action itself)
7
+ # status — :ok | :denied | :error
8
+ # message — safe text; the ONLY thing the model may see
9
+ # detail — internals (paths, policy names, exception class) for traces/logs only
10
+ # ms — duration in milliseconds
11
+ StepResult = Struct.new(:name, :status, :message, :detail, :ms, keyword_init: true)
12
+
13
+ # Outcome of Tool.call. Never an exception for expected failures.
14
+ #
15
+ # status — :ok | :denied | :error
16
+ # input — arguments as the tool received them
17
+ # output — value returned by +perform+ (nil unless :ok)
18
+ # failed_step — StepResult of the step that stopped the call (nil when :ok)
19
+ # steps — StepResult of every step that ran
20
+ # outcome_unknown — true when the tool reported that a side effect may have happened
21
+ ToolResult = Struct.new(:status, :input, :output, :failed_step, :steps, :outcome_unknown, keyword_init: true) do
22
+ def ok?; status == :ok; end
23
+ def denied?; status == :denied; end
24
+ def error?; status == :error; end
25
+
26
+ # Text for the `tool` message sent back to the model.
27
+ def model_message
28
+ case status
29
+ when :ok
30
+ output.is_a?(String) ? output : JSON.generate(output)
31
+ else
32
+ failed_step&.message.to_s
33
+ end
34
+ end
35
+
36
+ def execution_time
37
+ (steps || []).sum { |s| s.ms.to_i } / 1000.0
38
+ end
39
+ end
40
+
41
+ # A privileged action behind named, ordered steps (checks and transforms).
42
+ # The model never gets a shell — it can only call tools you wrote, and every
43
+ # call passes through your checks first.
44
+ #
45
+ # class LsTool < ActiveHarness::Tool
46
+ # tool_scope :os
47
+ # tool_name "ls"
48
+ # description "List files in a directory"
49
+ # param :path, "Directory to list"
50
+ #
51
+ # step(:no_hidden) { |input| deny!("hidden dirs are off limits") if input[:path].include?("/.") }
52
+ #
53
+ # def perform(input)
54
+ # Dir.children(input[:path]).first(200)
55
+ # end
56
+ # end
57
+ #
58
+ # LsTool.call(input: { path: "/srv" }, context: { user: current_user }) # => ToolResult
59
+ class Tool
60
+ # Raised (via #deny!) from any step or hook: the call is refused. +message+ is
61
+ # safe for the model; +detail+ stays in the trace.
62
+ class Denied < StandardError
63
+ attr_reader :detail
64
+
65
+ def initialize(message = nil, detail: nil)
66
+ super(message)
67
+ @detail = detail
68
+ end
69
+ end
70
+
71
+ # Raised (via #unknown_outcome!) when a side effect may or may not have happened
72
+ # (e.g. a timeout after a write was sent). Result: status :error, outcome_unknown: true.
73
+ # Deliberately NOT a Denied, so it never matches `rescue Denied`.
74
+ class UnknownOutcome < StandardError
75
+ attr_reader :detail
76
+
77
+ def initialize(message = nil, detail: nil)
78
+ super(message)
79
+ @detail = detail
80
+ end
81
+ end
82
+
83
+ Step = Struct.new(:name, :block, :transform)
84
+ PERFORM = Step.new(:perform, nil, true)
85
+
86
+ VALID_HOOKS = %i[before_step after_step before_perform after_perform].freeze
87
+ VALID_STEP_HOOKS = %i[before_step after_step].freeze
88
+
89
+ GENERIC_ERROR_MESSAGE = "tool failed, try another approach or finish".freeze
90
+ UNKNOWN_OUTCOME_MESSAGE = "result unknown, verify before retrying".freeze
91
+
92
+ # -------------------------------------------------------------------------
93
+ # Class-level DSL
94
+ # -------------------------------------------------------------------------
95
+ class << self
96
+ # --- what the model sees ---
97
+
98
+ # Required namespace: :os, :web, :ruby ... Full name is "tool_scope::tool_name".
99
+ # Both are explicit on purpose: the name the model sees must not change when a class is renamed.
100
+ def tool_scope(value = nil)
101
+ value ? @tool_scope = value.to_sym : @tool_scope
102
+ end
103
+
104
+ # Required: "ls", "fetch_url" ...
105
+ def tool_name(value = nil)
106
+ value ? @tool_name = value.to_s : @tool_name
107
+ end
108
+
109
+ def full_name
110
+ raise ArgumentError, "#{name || 'Tool'} must declare a tool_scope (e.g. `tool_scope :os`)" unless tool_scope
111
+ raise ArgumentError, "#{name || 'Tool'} must declare a tool_name (e.g. `tool_name \"ls\"`)" unless tool_name
112
+
113
+ "#{tool_scope}::#{tool_name}"
114
+ end
115
+
116
+ # Name on the wire: providers allow only [a-zA-Z0-9_-] in function names.
117
+ def wire_name
118
+ full_name.sub("::", "__")
119
+ end
120
+
121
+ def description(value = nil)
122
+ value ? @description = value : @description
123
+ end
124
+
125
+ # Declares one argument for the model.
126
+ #
127
+ # param :path, "Directory to list" # type: :string, required: true
128
+ # param :limit, "Max entries", type: :integer, required: false
129
+ # param :kind, "Entry kind", enum: %w[file dir]
130
+ def param(name, description, type: :string, required: true, enum: nil)
131
+ params << { name: name.to_sym, description: description, type: type, required: required, enum: enum }.reject { |_, v| v.nil? }
132
+ end
133
+
134
+ def params
135
+ @params ||= []
136
+ end
137
+
138
+ # Argument list (same data as +param+ builds); may be overridden by hand.
139
+ def schema
140
+ params
141
+ end
142
+
143
+ # Provider-neutral function schema; providers map it to their wire format.
144
+ # { name: "os__ls", description: "...", parameters: { type: "object", properties: {...}, required: [...] } }
145
+ def to_schema
146
+ props = {}
147
+ required = []
148
+ schema.each do |arg|
149
+ prop = { type: (arg[:type] || :string).to_s }
150
+ prop[:description] = arg[:description] if arg[:description]
151
+ prop[:enum] = arg[:enum] if arg[:enum]
152
+ props[arg[:name].to_s] = prop
153
+ required << arg[:name].to_s if arg.fetch(:required, true)
154
+ end
155
+
156
+ {
157
+ name: wire_name,
158
+ description: description.to_s,
159
+ parameters: { type: "object", properties: props, required: required }
160
+ }
161
+ end
162
+
163
+ # Checks arguments against +schema+. Returns an error message String or nil.
164
+ # Used by the Agent before running the tool (tools themselves get raw input).
165
+ def validate_input(input)
166
+ return "arguments must be an object" unless input.is_a?(Hash)
167
+
168
+ known = schema.map { |a| a[:name].to_sym }
169
+ extra = input.keys.map(&:to_sym) - known
170
+ return "unknown argument: #{extra.first}" unless extra.empty?
171
+
172
+ schema.each do |arg|
173
+ key = arg[:name].to_sym
174
+ value = input.key?(key) ? input[key] : input[key.to_s]
175
+ return "missing required argument: #{key}" if arg.fetch(:required, true) && value.nil?
176
+ if arg[:enum] && !value.nil? && !arg[:enum].include?(value)
177
+ return "invalid value for #{key}: expected one of #{arg[:enum].join(', ')}"
178
+ end
179
+ end
180
+ nil
181
+ end
182
+
183
+ # --- what the model does NOT see: ordered, named steps ---
184
+
185
+ # step :name do |input| ... end — a block
186
+ # step :name — calls the instance method of the same name
187
+ # step :name, transform: true { ... } — the return value replaces the payload for later steps and perform
188
+ def step(name, transform: false, &block)
189
+ steps << Step.new(name.to_sym, block, transform)
190
+ end
191
+
192
+ def steps
193
+ @steps ||= []
194
+ end
195
+
196
+ def hooks
197
+ @hooks ||= {}
198
+ end
199
+
200
+ def step_hooks
201
+ @step_hooks ||= {}
202
+ end
203
+
204
+ # Hooks run through instance_exec on the tool instance (they see context, payload, output).
205
+ #
206
+ # on :before_step, :resolve_path do |payload| ... end # per-step form
207
+ # on :before_step do |step_name, payload| ... end
208
+ # on :after_step do |step_name, step_result| ... end
209
+ # on :before_perform do |payload| ... end
210
+ # on :after_perform do |output| ... end
211
+ def on(event, step_name = nil, &block)
212
+ if step_name
213
+ raise ArgumentError, "Per-step hooks support: #{VALID_STEP_HOOKS.join(', ')}. Got :#{event}" unless VALID_STEP_HOOKS.include?(event)
214
+
215
+ ((step_hooks[step_name.to_sym] ||= {})[event] ||= []) << block
216
+ else
217
+ raise ArgumentError, "Unknown Tool hook :#{event}. Valid: #{VALID_HOOKS.join(', ')}" unless VALID_HOOKS.include?(event)
218
+
219
+ (hooks[event] ||= []) << block
220
+ end
221
+ end
222
+
223
+ def before(event, step_name = nil, &block)
224
+ on(:"before_#{event}", step_name, &block)
225
+ end
226
+
227
+ def after(event, step_name = nil, &block)
228
+ on(:"after_#{event}", step_name, &block)
229
+ end
230
+
231
+ def inherited(subclass)
232
+ super
233
+ subclass.instance_variable_set(:@tool_scope, @tool_scope)
234
+ subclass.instance_variable_set(:@steps, steps.dup)
235
+ subclass.instance_variable_set(:@params, params.map(&:dup))
236
+ subclass.instance_variable_set(:@hooks, hooks.each_with_object({}) { |(k, v), h| h[k] = v.dup })
237
+ subclass.instance_variable_set(:@step_hooks, step_hooks.each_with_object({}) { |(k, v), h| h[k] = v.transform_values(&:dup) })
238
+ end
239
+
240
+ # Same style as Request.call.
241
+ def call(input:, context: {})
242
+ new(context: context).call(input)
243
+ end
244
+ end
245
+
246
+ include Core::HookRunner
247
+
248
+ # -------------------------------------------------------------------------
249
+ # Instance API
250
+ # -------------------------------------------------------------------------
251
+ attr_reader :context
252
+ attr_accessor :payload, :output
253
+
254
+ def initialize(context: {})
255
+ @context = context
256
+ end
257
+
258
+ # Entry point used by the Agent loop. Never raises for expected failures.
259
+ def call(input)
260
+ @payload = input
261
+ @output = nil
262
+ steps = []
263
+
264
+ (self.class.steps + [PERFORM]).each do |step|
265
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
266
+ status, message, detail, unknown = :ok, nil, nil, false
267
+
268
+ begin
269
+ fire(:before_step, step.name, @payload)
270
+ fire_step(:before_step, step.name, @payload)
271
+ fire(:before_perform, @payload) if step.name == :perform
272
+
273
+ value = run_step(step)
274
+ @payload = value if step.transform
275
+
276
+ if step.name == :perform
277
+ @output = value
278
+ fire(:after_perform, @output)
279
+ end
280
+ rescue Denied => e
281
+ status, message, detail = :denied, e.message, e.detail
282
+ rescue UnknownOutcome => e
283
+ status, message, detail, unknown = :error, UNKNOWN_OUTCOME_MESSAGE, e.detail || e.message, true
284
+ rescue StandardError => e
285
+ status, message, detail = :error, GENERIC_ERROR_MESSAGE, "#{e.class}: #{e.message}"
286
+ end
287
+
288
+ steps << StepResult.new(name: step.name, status: status, message: message, detail: detail, ms: elapsed_ms(started))
289
+ fire(:after_step, step.name, steps.last)
290
+ fire_step(:after_step, step.name, steps.last)
291
+
292
+ next if status == :ok
293
+
294
+ return ToolResult.new(status: status, input: input, failed_step: steps.last, steps: steps, outcome_unknown: unknown)
295
+ end
296
+
297
+ ToolResult.new(status: :ok, input: input, output: @output, steps: steps, outcome_unknown: false)
298
+ end
299
+
300
+ # The action itself. Always the last step; its return value is the output.
301
+ def perform(_input)
302
+ raise NotImplementedError, "#{self.class.name} must implement #perform"
303
+ end
304
+
305
+ # Refuse the call. +msg+ is shown to the model; +detail+ only goes to the trace.
306
+ def deny!(msg, detail: nil)
307
+ raise Denied.new(msg, detail: detail)
308
+ end
309
+
310
+ # The side effect may have happened — the model is told to verify before retrying.
311
+ def unknown_outcome!(msg = nil, detail: nil)
312
+ raise UnknownOutcome.new(msg || "unknown outcome", detail: detail)
313
+ end
314
+
315
+ private
316
+
317
+ def run_step(step)
318
+ if step.name == :perform
319
+ perform(@payload)
320
+ elsif step.block
321
+ instance_exec(@payload, &step.block)
322
+ else
323
+ send(step.name, @payload)
324
+ end
325
+ end
326
+
327
+ def fire(event, *args)
328
+ run_hooks(self.class.hooks, event, *args)
329
+ end
330
+
331
+ # Per-step hook: receives only the data, not the step name.
332
+ def fire_step(event, step_name, data)
333
+ run_hooks(self.class.step_hooks[step_name] || {}, event, data)
334
+ end
335
+
336
+ def elapsed_ms(started)
337
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
338
+ end
339
+ end
340
+ end
341
+
342
+ require_relative "tool/path_policy"
@@ -75,7 +75,7 @@ module ActiveHarness
75
75
  def fire(event, *args)
76
76
  run_hook(event, *args)
77
77
  @stream&.call(:tribunal, event, *args)
78
- rescue IOError, ActionController::Live::ClientDisconnected
78
+ rescue ClientGone
79
79
  nil
80
80
  end
81
81
 
@@ -6,6 +6,7 @@ require_relative "active_harness/http/client"
6
6
  require_relative "active_harness/http/streaming_client"
7
7
  require_relative "active_harness/http/retry_policy"
8
8
  require_relative "active_harness/providers/base"
9
+ require_relative "active_harness/providers/tool_calling"
9
10
  require_relative "active_harness/providers/openai"
10
11
  require_relative "active_harness/providers/openrouter"
11
12
  require_relative "active_harness/providers/groq"
@@ -37,11 +38,13 @@ require_relative "active_harness/memory"
37
38
  require_relative "active_harness/request"
38
39
  require_relative "active_harness/tribunal"
39
40
  require_relative "active_harness/pipeline"
41
+ require_relative "active_harness/tool"
42
+ require_relative "active_harness/agent"
40
43
 
41
44
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
42
45
 
43
46
  module ActiveHarness
44
- VERSION = "0.3.8"
47
+ VERSION = "0.3.10"
45
48
 
46
49
  class << self
47
50
  # Configure ActiveHarness.
@@ -0,0 +1,16 @@
1
+ require "rails/generators"
2
+
3
+ module ActiveHarness
4
+ module Generators
5
+ class AgentGenerator < Rails::Generators::NamedBase
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates an ActiveHarness agent in app/ai/agents/"
9
+
10
+ def create_agent
11
+ template "agent.rb.tt",
12
+ "app/ai/agents/#{file_name}_agent.rb"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ class <%= class_name %>Agent < ActiveHarness::Agent
2
+ # The Request that talks to the model each turn (owns model chain, retries, fallback).
3
+ request <%= class_name %>Request
4
+
5
+ # The only tools the model may call (default deny: anything else is "unknown tool").
6
+ # tools ExampleTool
7
+
8
+ # Optional: judge the final answer; without it the first answer without tool calls ends the run.
9
+ # goal <%= class_name %>Goal
10
+
11
+ # Hard limits so a looping model cannot run or spend forever.
12
+ max_turns 10
13
+ max_cost 0.50
14
+ end
@@ -0,0 +1,16 @@
1
+ require "rails/generators"
2
+
3
+ module ActiveHarness
4
+ module Generators
5
+ class GoalGenerator < Rails::Generators::NamedBase
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates an ActiveHarness goal check in app/ai/goals/"
9
+
10
+ def create_goal
11
+ template "goal.rb.tt",
12
+ "app/ai/goals/#{file_name}_goal.rb"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ # A goal check judges the agent's final answer: override #call(candidate:, goal:, state:)
2
+ # to return a GoalCheckResult. Plug it in with `goal <%= class_name %>Goal`.
3
+ # Prefer deterministic checks (tests, schemas, counts) to asking an LLM.
4
+ class <%= class_name %>Goal < ActiveHarness::GoalCheck
5
+ def call(candidate:, goal:, state:)
6
+ if candidate.to_s.strip.empty?
7
+ # :continue sends the feedback to the model for another turn (up to max_goal_retries).
8
+ # Other statuses: :achieved, :replan, :needs_input, :failed.
9
+ ActiveHarness::GoalCheckResult.new(status: :continue, feedback: "The answer is empty. Answer the question.")
10
+ else
11
+ ActiveHarness::GoalCheckResult.new(status: :achieved)
12
+ end
13
+ end
14
+ end
@@ -3,16 +3,26 @@ require "rails/generators"
3
3
  module ActiveHarness
4
4
  module Generators
5
5
  class InstallGenerator < Rails::Generators::Base
6
- desc "Creates the app/ai/ directory structure for ActiveHarness"
6
+ desc "Creates the app/ai/ directory structure for ActiveHarness.\n" \
7
+ "Safe to re-run: existing files are never touched, only missing example files are added."
7
8
 
8
9
  source_root File.expand_path("templates", __dir__)
9
10
 
10
- APP_AI_DIRS = %w[requests prompts tribunals pipelines memory].freeze
11
+ ROUTES = {
12
+ "ai/agent" => 'post "ai/agent", to: "ai_support#agent" # not "request" — would override ActionController::Base#request',
13
+ "ai/request_memory" => 'post "ai/request_memory", to: "ai_support#request_memory"',
14
+ "ai/tribunal" => 'post "ai/tribunal", to: "ai_support#tribunal"',
15
+ "ai/pipeline" => 'post "ai/pipeline", to: "ai_support#pipeline"',
16
+ "ai/files_agent" => 'post "ai/files_agent", to: "ai_support#files_agent"',
17
+ "ai/request_stream" => 'get "ai/request_stream", to: "ai_support#request_stream"'
18
+ }.freeze
19
+
20
+ APP_AI_DIRS = %w[concerns requests prompts tribunals pipelines memory agents tools goals].freeze
11
21
 
12
22
  def create_structure
13
23
  APP_AI_DIRS.each do |dir|
14
24
  empty_directory "app/ai/#{dir}"
15
- copy_templates_if_empty(dir)
25
+ copy_missing_templates(dir)
16
26
  end
17
27
  end
18
28
 
@@ -24,14 +34,6 @@ module ActiveHarness
24
34
  "config/initializers/active_harness.rb"
25
35
  end
26
36
 
27
- def create_initializer
28
- target = File.join(destination_root, "config", "initializers", "active_harness.rb")
29
- return if File.exist?(target)
30
-
31
- copy_file "initializers/active_harness.rb",
32
- "config/initializers/active_harness.rb"
33
- end
34
-
35
37
  def copy_controller
36
38
  target = File.join(destination_root, "app", "controllers", "ai_support_controller.rb")
37
39
  return if File.exist?(target)
@@ -40,29 +42,32 @@ module ActiveHarness
40
42
  "app/controllers/ai_support_controller.rb"
41
43
  end
42
44
 
45
+ # Adds only the routes that are not in config/routes.rb yet, so re-running is harmless.
43
46
  def inject_routes
44
- route <<~ROUTES.strip
45
- # ActiveHarness — AI support endpoints
46
- post "ai/agent", to: "ai_support#agent" # not "request" — would override ActionController::Base#request
47
- post "ai/request_memory", to: "ai_support#request_memory"
48
- post "ai/tribunal", to: "ai_support#tribunal"
49
- post "ai/pipeline", to: "ai_support#pipeline"
50
- get "ai/request_stream", to: "ai_support#request_stream"
51
- ROUTES
47
+ routes_file = File.join(destination_root, "config", "routes.rb")
48
+ existing = File.exist?(routes_file) ? File.read(routes_file) : ""
49
+ missing = ROUTES.reject { |path, _line| existing.include?(%("#{path}")) }
50
+ return if missing.empty?
51
+
52
+ route(["# ActiveHarness — AI support endpoints", *missing.map { |_path, line| line }].join("\n"))
52
53
  end
53
54
 
54
55
  private
55
56
 
56
- def copy_templates_if_empty(dir)
57
- target_dir = File.join(destination_root, "app", "ai", dir)
58
- return if Dir.glob(File.join(target_dir, "*.rb")).any?
59
-
57
+ # Per-file, never overwrites: a template is copied only when its target does not exist.
58
+ # Examples depend on each other (agent -> request/tools/goal/concerns), so a missing
59
+ # piece is added even when its directory already holds the user's own files.
60
+ def copy_missing_templates(dir)
60
61
  template_dir = File.join(self.class.source_root, dir)
61
62
  return unless Dir.exist?(template_dir)
62
63
 
63
64
  Dir[File.join(template_dir, "*.rb")].sort.each do |tpl|
64
- copy_file File.join(dir, File.basename(tpl)),
65
- File.join("app", "ai", dir, File.basename(tpl))
65
+ relative = File.join("app", "ai", dir, File.basename(tpl))
66
+ if File.exist?(File.join(destination_root, relative))
67
+ say_status :skip, "#{relative} (exists)", :yellow
68
+ else
69
+ copy_file File.join(dir, File.basename(tpl)), relative
70
+ end
66
71
  end
67
72
  end
68
73
  end