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
@@ -0,0 +1,39 @@
1
+ require_relative "../requests/files_request"
2
+ require_relative "../tools/ls_tool"
3
+ require_relative "../tools/read_file_tool"
4
+ require_relative "../goals/answer_present_goal"
5
+ require_relative "../concerns/agent_tracing"
6
+
7
+ # A tool-calling agent: the model decides which tool to call, the agent validates
8
+ # the call, runs the allowed tools and feeds the results back until an answer or a limit.
9
+ #
10
+ # agent = FilesAgent.call(input: "What is in the workspace?")
11
+ # agent.output # final answer
12
+ # agent.stop_reason # :goal_reached | :max_turns | ...
13
+ # puts agent.trace
14
+ class FilesAgent < ActiveHarness::Agent
15
+ include AgentTracing
16
+
17
+ # Which Request talks to the model each turn. It owns the model chain, retries and
18
+ # fallback — the agent never repeats a tool call just because a model failed.
19
+ # (Name more than one with `request :cheap, CheapRequest` and pick with `request_for`.)
20
+ request FilesRequest
21
+
22
+ # The only tools the model may call. Default deny: any other name is refused as
23
+ # "unknown tool" and never executed, however the model asks.
24
+ tools LsTool, ReadFileTool
25
+
26
+ # "The model stopped asking for tools" is not proof the job is done. A goal check
27
+ # judges the final answer and can send the agent back for another turn.
28
+ # Without `goal`, the first answer without tool calls ends the run.
29
+ goal AnswerPresentGoal
30
+
31
+ # Hard limits: a model that loops or wanders must not run (or spend) forever.
32
+ # Whichever trips first ends the run with a matching stop_reason.
33
+ max_turns 8 # at most 8 Request calls
34
+ max_cost 0.50 # USD summed over all turns; stops NEW steps, a running call may overshoot
35
+
36
+ # Stop early when the model repeats the same tool call with the same input
37
+ # several turns in a row (stop_reason: :repeating).
38
+ loop_guard
39
+ end
@@ -0,0 +1,36 @@
1
+ require_relative "tracing_log"
2
+
3
+ # Basic tracing for any Agent: turns, tool calls (with refusals), the stop
4
+ # reason and a final summary.
5
+ #
6
+ # class FilesAgent < ActiveHarness::Agent
7
+ # include AgentTracing
8
+ # end
9
+ module AgentTracing
10
+ def self.included(base)
11
+ base.include TracingLog
12
+
13
+ base.on(:before_turn) do |turn|
14
+ trace_log "turn #{turn.number}"
15
+ end
16
+
17
+ base.on(:after_tool) do |call, result|
18
+ if result.status == :ok
19
+ trace_log " tool #{call.full_name} ok #{call.input.inspect}"
20
+ else
21
+ step = result.failed_step
22
+ trace_log " tool #{call.full_name} #{result.status} at #{step.name}: #{step.message}#{" [#{step.detail}]" if step.detail}", level: :warn
23
+ end
24
+ end
25
+
26
+ base.on(:stopped) do |reason|
27
+ trace_log "stopped: #{reason}", level: :warn
28
+ end
29
+
30
+ base.on(:complete) do |result|
31
+ usage = result.usage
32
+ trace_log "#{status}/#{stop_reason} — #{turns.size} turns, #{tool_calls.size} tool calls (#{denied_calls.size} refused or failed), " \
33
+ "#{usage&.tokens&.total.to_i} tokens#{format(", $%.6f", usage.cost.total) if usage&.cost}, #{result.execution_time}s"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,28 @@
1
+ require_relative "tracing_log"
2
+
3
+ # Basic tracing for any Pipeline: every step, a stop, completion.
4
+ #
5
+ # class SupportPipeline < ActiveHarness::Pipeline
6
+ # include PipelineTracing
7
+ # end
8
+ module PipelineTracing
9
+ def self.included(base)
10
+ base.include TracingLog
11
+
12
+ base.before(:step) do |step_name, _payload|
13
+ trace_log "→ :#{step_name}"
14
+ end
15
+
16
+ base.after(:step) do |step_name, result|
17
+ trace_log "✓ :#{step_name} (#{result.execution_time}s)"
18
+ end
19
+
20
+ base.callback(:stopped) do |step_name, _result|
21
+ trace_log "✗ stopped at :#{step_name}", level: :warn
22
+ end
23
+
24
+ base.callback(:complete) do |_last_result|
25
+ trace_log "✓ complete (#{execution_time}s)"
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,44 @@
1
+ require_relative "tracing_log"
2
+
3
+ # Basic tracing for any Request: one line before the call, one after it
4
+ # (model, time, tokens, cost), plus retries and total failure.
5
+ #
6
+ # class SupportRequest < ActiveHarness::Request
7
+ # include RequestTracing
8
+ # end
9
+ #
10
+ # Add request-specific details by overriding #tracing_details:
11
+ #
12
+ # def tracing_details(result)
13
+ # " spam=#{result.processed&.dig('spam')}"
14
+ # end
15
+ module RequestTracing
16
+ def self.included(base)
17
+ base.include TracingLog
18
+
19
+ base.before(:call) do
20
+ trace_log "▶ call"
21
+ end
22
+
23
+ base.after(:call) do |result|
24
+ tokens = result.usage&.tokens&.total
25
+ cost = result.usage&.cost&.total
26
+ trace_log "✓ #{result.model.name} #{result.execution_time}s" \
27
+ "#{" tokens=#{tokens}" if tokens}#{format(" cost=$%.6f", cost) if cost}" \
28
+ "#{tracing_details(result)}"
29
+ end
30
+
31
+ base.callback(:retry) do |entry, error|
32
+ trace_log "↺ retry #{entry&.dig(:model)} — #{error&.message}", level: :warn
33
+ end
34
+
35
+ base.callback(:failure) do |attempts|
36
+ trace_log "✗ all models failed (#{attempts.size} attempts)", level: :error
37
+ end
38
+ end
39
+
40
+ # Override to append request-specific details to the "done" line.
41
+ def tracing_details(_result)
42
+ ""
43
+ end
44
+ end
@@ -0,0 +1,21 @@
1
+ require_relative "tracing_log"
2
+
3
+ # Basic tracing for any Tool: every step with its status. A refused or failed
4
+ # step is logged with its internal `detail` — that text never reaches the model.
5
+ #
6
+ # class LsTool < ActiveHarness::Tool
7
+ # include ToolTracing
8
+ # end
9
+ module ToolTracing
10
+ def self.included(base)
11
+ base.include TracingLog
12
+
13
+ base.on(:after_step) do |step_name, step|
14
+ if step.status == :ok
15
+ trace_log "✓ #{step_name} (#{step.ms}ms)", level: :debug
16
+ else
17
+ trace_log "✗ #{step_name} #{step.status}: #{step.message}#{" [#{step.detail}]" if step.detail}", level: :warn
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # Shared by every *Tracing concern: ONE place that decides where trace lines go.
2
+ # Today: Rails.logger. Swap this method to emit OpenTelemetry spans, StatsD
3
+ # metrics or database rows, and every request/tribunal/pipeline/agent/tool that
4
+ # includes a tracing concern follows — the examples themselves stay untouched.
5
+ module TracingLog
6
+ private
7
+
8
+ def trace_log(message, level: :info)
9
+ logger = defined?(Rails) && Rails.logger ? Rails.logger : Logger.new($stdout)
10
+ logger.public_send(level, "[#{tracing_label}] #{message}")
11
+ end
12
+
13
+ # Override in a class to rename it in the logs.
14
+ def tracing_label
15
+ klass = self.class
16
+ klass.respond_to?(:full_name) ? klass.full_name : klass.name
17
+ end
18
+ end
@@ -0,0 +1,28 @@
1
+ require_relative "tracing_log"
2
+
3
+ # Basic tracing for any Tribunal: start, each finished request, request errors, verdict.
4
+ #
5
+ # class SupportGuardTribunal < ActiveHarness::Tribunal
6
+ # include TribunalTracing
7
+ # end
8
+ module TribunalTracing
9
+ def self.included(base)
10
+ base.include TracingLog
11
+
12
+ base.on(:before_call) do
13
+ trace_log "▶ tribunal started"
14
+ end
15
+
16
+ base.on(:after_request) do |result, index|
17
+ trace_log "✓ request #{index + 1} done (#{result.execution_time}s) — #{result.processed}"
18
+ end
19
+
20
+ base.on(:request_error) do |name, error, _index|
21
+ trace_log "✗ #{name} failed — #{error&.message}", level: :warn
22
+ end
23
+
24
+ base.on(:after_verdict) do |verdict|
25
+ trace_log "verdict: #{verdict.inspect} (#{execution_time}s)", level: verdict ? :info : :warn
26
+ end
27
+ end
28
+ end
@@ -12,7 +12,7 @@ class AiSupportController < ApplicationController
12
12
  # request.env / CSRF / anything else that relies on the real HTTP request.
13
13
  # ---------------------------------------------------------------------------
14
14
  def agent
15
- result = SupportRequest.call(input: params.require(:input))
15
+ result = SupportRequest.call(input: params.require(:input)).result
16
16
 
17
17
  render json: {
18
18
  output: result.output,
@@ -30,7 +30,7 @@ class AiSupportController < ApplicationController
30
30
  # ---------------------------------------------------------------------------
31
31
  def request_memory
32
32
  memory = AppMemory.new(session_id: params.require(:session_id))
33
- result = SupportRequest.call(input: params.require(:input), memory: memory)
33
+ result = SupportRequest.call(input: params.require(:input), memory: memory).result
34
34
 
35
35
  render json: {
36
36
  output: result.output,
@@ -74,6 +74,27 @@ class AiSupportController < ApplicationController
74
74
  end
75
75
  end
76
76
 
77
+ # ---------------------------------------------------------------------------
78
+ # POST /ai/files_agent
79
+ # body: { input: "What files are in storage and what is in the first one?" }
80
+ #
81
+ # Runs FilesAgent: the model calls os::ls / os::read_file (inside storage/ only)
82
+ # until it answers or a limit stops it. `trace` shows every turn and tool call.
83
+ # ---------------------------------------------------------------------------
84
+ def files_agent
85
+ agent = FilesAgent.call(input: params.require(:input))
86
+
87
+ render json: {
88
+ status: agent.status,
89
+ stop_reason: agent.stop_reason,
90
+ output: agent.output,
91
+ turns: agent.turns.size,
92
+ tool_calls: agent.tool_calls.size,
93
+ cost: agent.usage&.cost&.total,
94
+ trace: agent.trace
95
+ }
96
+ end
97
+
77
98
  # ---------------------------------------------------------------------------
78
99
  # GET /ai/request_stream?input=What+is+your+return+policy%3F
79
100
  #
@@ -0,0 +1,22 @@
1
+ # A goal check judges the agent's final answer. Subclass ActiveHarness::GoalCheck and
2
+ # override #call(candidate:, goal:, state:) to return a GoalCheckResult; plug it in with
3
+ # `goal AnswerPresentGoal` in the agent. (The base class only raises a clear error if you
4
+ # forget #call — any object with that method works too.)
5
+ #
6
+ # candidate — the answer the model proposed
7
+ # goal — what was asked (goal.task, goal.success_criteria)
8
+ # state — the whole run (messages, events, tool calls) as evidence
9
+ #
10
+ # Prefer deterministic checks (tests, schemas, counts) to asking an LLM.
11
+ class AnswerPresentGoal < ActiveHarness::GoalCheck
12
+ def call(candidate:, goal:, state:)
13
+ if candidate.to_s.strip.empty?
14
+ # :continue — the feedback is sent to the model and the agent takes another turn
15
+ # (at most `max_goal_retries`, default 2, then the run stops with :goal_failed).
16
+ # Other statuses: :achieved, :replan, :needs_input, :failed.
17
+ ActiveHarness::GoalCheckResult.new(status: :continue, feedback: "The answer is empty. Answer the question.")
18
+ else
19
+ ActiveHarness::GoalCheckResult.new(status: :achieved)
20
+ end
21
+ end
22
+ end
@@ -1,31 +1,21 @@
1
1
  require_relative "../tribunals/support_guard_tribunal"
2
2
  require_relative "../requests/support_request"
3
+ require_relative "../concerns/pipeline_tracing"
3
4
 
4
5
  # Two-step pipeline: spam guard → answer.
5
6
  # Add more steps between them as needed.
6
7
  class SupportPipeline < ActiveHarness::Pipeline
7
- # Step 1 — GUARD: reject spam before spending tokens on an answer
8
+ include PipelineTracing
9
+
10
+ # A GUARD step: cheap checks first, so no tokens are spent on an answer that
11
+ # should never be generated. A tribunal step only judges — it does not change
12
+ # the text that flows to the next step.
8
13
  step :spam_guard do
9
14
  use SupportGuardTribunal
15
+ # Stops the pipeline when true: pipeline.stopped? is set and later steps are skipped.
10
16
  stop_if ->(result) { result.processed["verdict"] == false }
11
17
  end
12
18
 
13
- # Step 2 — RESPOND: generate the actual answer
19
+ # A TRANSFORM step: `step :name, SomeRequest` — its output becomes the input of the next step.
14
20
  step :respond, SupportRequest
15
-
16
- before :step do |step_name, _payload|
17
- puts "[pipeline] → :#{step_name}"
18
- end
19
-
20
- after :step do |step_name, _result|
21
- puts "[pipeline] ✓ :#{step_name}"
22
- end
23
-
24
- callback :stopped do |step_name, _result|
25
- puts "[pipeline] ✗ STOPPED at :#{step_name}"
26
- end
27
-
28
- callback :complete do |_last_result|
29
- puts "[pipeline] ✓ complete"
30
- end
31
21
  end
@@ -0,0 +1,9 @@
1
+ class FilesPrompt
2
+ def call
3
+ <<~PROMPT.strip
4
+ You are a file assistant for a workspace directory.
5
+ Use the tools to list directories and read files, then answer briefly.
6
+ If a tool is refused or fails, do not retry the same call: try another approach or tell the user what is not possible.
7
+ PROMPT
8
+ end
9
+ end
@@ -1,3 +1,6 @@
1
+ # A prompt is a plain Ruby class with #call returning the system prompt text.
2
+ # Before #call the Request injects @input, @context, @params, @memory and
3
+ # @context_window, so a prompt can be built from them.
1
4
  class SupportPrompt
2
5
  def call
3
6
  "You are a concise and helpful assistant. Answer in 1-2 sentences."
@@ -0,0 +1,18 @@
1
+ require_relative "../prompts/files_prompt"
2
+ require_relative "../concerns/request_tracing"
3
+
4
+ # The model behind FilesAgent. It must support tool calling.
5
+ class FilesRequest < ActiveHarness::Request
6
+ include RequestTracing # basic tracing lives in a concern — see app/ai/concerns/
7
+
8
+ # The system prompt: a class with #call, a String or a lambda. The agent adds the
9
+ # conversation and the tool schemas on top of it.
10
+ system_prompt FilesPrompt
11
+
12
+ # The model chain: `use` is tried first; on failure (after retries) the next
13
+ # `fallback` is tried. Not every provider supports tools — one that does not is skipped.
14
+ model do
15
+ use provider: :openrouter, model: "openai/gpt-4.1-mini"
16
+ fallback provider: :openrouter, model: "google/gemini-2.0-flash-001"
17
+ end
18
+ end
@@ -1,11 +1,23 @@
1
1
  require_relative "../prompts/support_guard_prompt"
2
+ require_relative "../concerns/request_tracing"
2
3
 
3
4
  class SupportGuardRequest < ActiveHarness::Request
5
+ include RequestTracing
6
+
4
7
  system_prompt SupportGuardPrompt
8
+
9
+ # Parse the answer as JSON into result.processed (a Hash). Needed explicitly: the gem
10
+ # cannot guess from the prompt that you asked for JSON. Also strips ```json fences.
5
11
  format :json
6
12
 
13
+ # A small, cheap model is enough for a yes/no classifier.
7
14
  model do
8
15
  use provider: :openrouter, model: "meta-llama/llama-3.1-8b-instruct"
9
16
  fallback provider: :openrouter, model: "mistralai/mistral-nemo"
10
17
  end
18
+
19
+ # Request-specific details for the shared tracing line (see RequestTracing).
20
+ def tracing_details(result)
21
+ " spam=#{result.processed&.dig('spam')}"
22
+ end
11
23
  end
@@ -1,8 +1,14 @@
1
1
  require_relative "../prompts/support_prompt"
2
+ require_relative "../concerns/request_tracing"
2
3
 
3
4
  class SupportRequest < ActiveHarness::Request
5
+ include RequestTracing # basic tracing lives in a concern — see app/ai/concerns/
6
+
7
+ # The system prompt: a class with #call, a String or a lambda.
4
8
  system_prompt SupportPrompt
5
9
 
10
+ # The model chain: `use` is tried first; if it keeps failing (after its retries)
11
+ # the next `fallback` is tried. When all fail: ActiveHarness::Errors::AllModelsFailed.
6
12
  model do
7
13
  use provider: :openrouter, model: "mistralai/mistral-nemo"
8
14
  fallback provider: :openrouter, model: "meta-llama/llama-3.1-8b-instruct"
@@ -0,0 +1,41 @@
1
+ require_relative "../concerns/tool_tracing"
2
+
3
+ # A safe directory listing: the model can only see inside ALLOWED_PATHS.
4
+ class LsTool < ActiveHarness::Tool
5
+ include ActiveHarness::Tool::PathPolicy # safe_path!: resolves symlinks and ".." and checks ALLOWED_PATHS
6
+ include ToolTracing # logs every step; see app/ai/concerns/
7
+
8
+ # `tool_scope` namespaces the tool (os::ls vs web::ls) and lets you allow or log a whole group;
9
+ # `tool_name` is the name the model sees. Both are explicit: renaming the class changes nothing for the model.
10
+ tool_scope :os
11
+ tool_name "ls"
12
+
13
+ # What the model reads when it decides whether to call this tool — write it for the model.
14
+ description "List files in a directory inside the workspace"
15
+
16
+ # An argument the model must provide. `param` only DESCRIBES it to the model; the
17
+ # agent rejects missing or unknown arguments, and the checks below do the real policing.
18
+ param :path, "Directory to list (absolute path inside the workspace)"
19
+
20
+ # Fail closed: PathPolicy raises if a tool using it forgets to define this.
21
+ ALLOWED_PATHS = [Rails.root.join("storage").to_s].freeze
22
+
23
+ # Steps run in order BEFORE #perform. `transform: true` means the return value
24
+ # replaces the input for every later step and for #perform — so after this step the
25
+ # tool only ever sees a canonical path inside ALLOWED_PATHS.
26
+ step :resolve_path, transform: true do |input|
27
+ input.merge(path: safe_path!(input[:path]))
28
+ end
29
+
30
+ # A plain check: refuse with deny!. The text goes back to the model so it can adjust;
31
+ # pass `detail:` for anything that must stay in the logs only.
32
+ step :no_hidden_dirs do |input|
33
+ deny!("hidden dirs are off limits") if input[:path].split("/").any? { |segment| segment.start_with?(".") }
34
+ end
35
+
36
+ # The action itself — always last; its return value is what the model gets back.
37
+ # Cap the output: a huge result floods the model's context.
38
+ def perform(input)
39
+ Dir.children(input[:path]).first(200)
40
+ end
41
+ end
@@ -0,0 +1,44 @@
1
+ require_relative "../concerns/tool_tracing"
2
+
3
+ # Reads the first lines of a text file inside the workspace. Size and line caps
4
+ # keep a single call from flooding the model's context.
5
+ class ReadFileTool < ActiveHarness::Tool
6
+ include ActiveHarness::Tool::PathPolicy
7
+ include ToolTracing
8
+
9
+ tool_scope :os # full name: os::read_file
10
+ tool_name "read_file"
11
+ description "Read the first lines of a text file inside the workspace"
12
+
13
+ param :path, "Absolute path of the file"
14
+ # Optional arguments: `required: false`; `type:` is :string by default (also :integer, :boolean, ...);
15
+ # `enum: [...]` limits the allowed values.
16
+ param :lines, "How many lines to read (default 50, max 100)", type: :integer, required: false
17
+
18
+ ALLOWED_PATHS = [Rails.root.join("storage").to_s].freeze
19
+ MAX_BYTES = 200_000
20
+ MAX_LINES = 100
21
+
22
+ # Canonicalize first, then check — validating the raw string would miss symlinks and "..".
23
+ step :resolve_path, transform: true do |input|
24
+ input.merge(path: safe_path!(input[:path]))
25
+ end
26
+
27
+ step :no_hidden_files do |input|
28
+ deny!("hidden files are off limits") if input[:path].split("/").any? { |segment| segment.start_with?(".") }
29
+ end
30
+
31
+ # `detail:` is for the logs only — the model just learns that the file is too large.
32
+ step :must_be_a_small_file do |input|
33
+ deny!("not a regular file") unless File.file?(input[:path])
34
+ deny!("file is too large", detail: "#{File.size(input[:path])} bytes") if File.size(input[:path]) > MAX_BYTES
35
+ end
36
+
37
+ # Anything raised here (other than deny!) becomes a generic "tool failed" for the
38
+ # model; the real error stays in the trace. If a write may have happened but you
39
+ # cannot tell (e.g. a timeout), call unknown_outcome!("...") instead of retrying.
40
+ def perform(input)
41
+ count = [(input[:lines] || 50).to_i, MAX_LINES].min
42
+ File.foreach(input[:path]).first(count).join
43
+ end
44
+ end
@@ -1,10 +1,17 @@
1
1
  require_relative "../requests/support_guard_request"
2
+ require_relative "../concerns/tribunal_tracing"
2
3
 
3
4
  # Runs SupportGuardRequest in parallel (single request here, extendable).
4
5
  # Verdict is true (safe) when no spam is detected.
5
6
  class SupportGuardTribunal < ActiveHarness::Tribunal
7
+ include TribunalTracing
8
+
9
+ # The requests that run IN PARALLEL on the same input. Several different checks —
10
+ # or the same check on different models — give a more reliable decision than one call.
6
11
  requests SupportGuardRequest
7
12
 
13
+ # Reduces all successful results to the verdict (true/false). Built-in shortcuts:
14
+ # `verdict :unanimous { ... }` and `verdict :majority { ... }`.
8
15
  process do |results|
9
16
  results.none? { |r| r.processed["spam"] == true }
10
17
  end
@@ -1,6 +1,11 @@
1
1
  class <%= class_name %>Pipeline < ActiveHarness::Pipeline
2
+ # A transform step: its output becomes the input of the next step.
3
+ # A guard step (`step :x do use SomeRequest; stop_if ->(r) { ... } end`) can stop the pipeline.
2
4
  step :respond, <%= class_name %>Request
3
5
 
6
+ # Hooks: `before :step` / `after :step` fire around every step, `callback :stopped`
7
+ # when a stop_if triggers, `callback :complete` after the last step.
8
+ # For reusable logging, move them into a concern (see app/ai/concerns/pipeline_tracing.rb).
4
9
  before :step do |step_name, _payload|
5
10
  puts "[pipeline] → :#{step_name}"
6
11
  end
@@ -1,6 +1,8 @@
1
1
  class <%= class_name %>Request < ActiveHarness::Request
2
+ # The system prompt: a class with #call, a String or a lambda.
2
3
  system_prompt <%= class_name %>Prompt
3
4
 
5
+ # `use` is tried first; on failure the next `fallback` is tried.
4
6
  model do
5
7
  use provider: :openrouter, model: "mistralai/mistral-nemo"
6
8
  fallback provider: :openrouter, model: "meta-llama/llama-3.1-8b-instruct"
@@ -0,0 +1,24 @@
1
+ class <%= class_name %>Tool < ActiveHarness::Tool
2
+ # Namespace + name the model sees: <%= file_name %> becomes app::<%= file_name %> (wire name app__<%= file_name %>).
3
+ # Both are explicit, so renaming this class never changes what the model calls. Use :os, :web, :ruby ... per area.
4
+ tool_scope :app
5
+ tool_name "<%= file_name %>"
6
+
7
+ # Written for the model: when to call this tool.
8
+ description "Describe what this tool does for the model"
9
+
10
+ # An argument the model provides. It only DESCRIBES the argument — real policing is in the steps.
11
+ param :input, "What the model must pass"
12
+
13
+ # Checks run in order before #perform; refuse with deny!("safe text for the model")
14
+ # (add `detail:` for anything that must stay in the logs). `transform: true` lets a step
15
+ # return a new input for the later steps and #perform.
16
+ # step :allowed do |input|
17
+ # deny!("not allowed") unless context[:user]
18
+ # end
19
+
20
+ # The action itself; its return value goes back to the model. Cap the size of the result.
21
+ def perform(input)
22
+ "result for #{input[:input]}"
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ require "rails/generators"
2
+
3
+ module ActiveHarness
4
+ module Generators
5
+ class ToolGenerator < Rails::Generators::NamedBase
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates an ActiveHarness tool in app/ai/tools/"
9
+
10
+ def create_tool
11
+ template "tool.rb.tt",
12
+ "app/ai/tools/#{file_name}_tool.rb"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -1,6 +1,9 @@
1
1
  class <%= class_name %>Tribunal < ActiveHarness::Tribunal
2
+ # Requests that run in parallel on the same input.
2
3
  requests <%= class_name %>Request
3
4
 
5
+ # Reduces the successful results to the verdict (true/false).
6
+ # Shortcuts: `verdict :unanimous { ... }` / `verdict :majority { ... }`.
4
7
  process do |results|
5
8
  results.all? { |r| r.processed["result"] == true }
6
9
  end