active_harness 0.3.8 → 0.3.9
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 +4 -4
- data/lib/active_harness/agent/goal_check.rb +26 -0
- data/lib/active_harness/agent/state.rb +90 -0
- data/lib/active_harness/agent.rb +734 -0
- data/lib/active_harness/core/errors.rb +11 -0
- data/lib/active_harness/pipeline/hooks.rb +1 -1
- data/lib/active_harness/providers/anthropic.rb +57 -8
- data/lib/active_harness/providers/base.rb +6 -0
- data/lib/active_harness/providers/deepseek.rb +7 -4
- data/lib/active_harness/providers/gpustack.rb +7 -4
- data/lib/active_harness/providers/groq.rb +7 -4
- data/lib/active_harness/providers/hetzner.rb +7 -4
- data/lib/active_harness/providers/mistral.rb +7 -4
- data/lib/active_harness/providers/ollama.rb +7 -4
- data/lib/active_harness/providers/ollama_cloud.rb +7 -4
- data/lib/active_harness/providers/openai.rb +7 -4
- data/lib/active_harness/providers/openrouter.rb +7 -4
- data/lib/active_harness/providers/tool_calling.rb +95 -0
- data/lib/active_harness/providers/xai.rb +7 -4
- data/lib/active_harness/railtie.rb +1 -1
- data/lib/active_harness/request/hooks.rb +1 -1
- data/lib/active_harness/request/providers.rb +15 -0
- data/lib/active_harness/request.rb +39 -7
- data/lib/active_harness/result.rb +4 -0
- data/lib/active_harness/tool/path_policy.rb +28 -0
- data/lib/active_harness/tool.rb +348 -0
- data/lib/active_harness/tribunal/hooks.rb +1 -1
- data/lib/active_harness.rb +4 -1
- data/lib/generators/active_harness/agent/agent_generator.rb +16 -0
- data/lib/generators/active_harness/agent/templates/agent.rb.tt +14 -0
- data/lib/generators/active_harness/goal/goal_generator.rb +16 -0
- data/lib/generators/active_harness/goal/templates/goal.rb.tt +14 -0
- data/lib/generators/active_harness/install/install_generator.rb +30 -25
- data/lib/generators/active_harness/install/templates/agents/files_agent.rb +39 -0
- data/lib/generators/active_harness/install/templates/concerns/agent_tracing.rb +36 -0
- data/lib/generators/active_harness/install/templates/concerns/pipeline_tracing.rb +28 -0
- data/lib/generators/active_harness/install/templates/concerns/request_tracing.rb +44 -0
- data/lib/generators/active_harness/install/templates/concerns/tool_tracing.rb +21 -0
- data/lib/generators/active_harness/install/templates/concerns/tracing_log.rb +18 -0
- data/lib/generators/active_harness/install/templates/concerns/tribunal_tracing.rb +28 -0
- data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +23 -2
- data/lib/generators/active_harness/install/templates/goals/answer_present_goal.rb +22 -0
- data/lib/generators/active_harness/install/templates/pipelines/support_pipeline.rb +8 -18
- data/lib/generators/active_harness/install/templates/prompts/files_prompt.rb +9 -0
- data/lib/generators/active_harness/install/templates/prompts/support_prompt.rb +3 -0
- data/lib/generators/active_harness/install/templates/requests/files_request.rb +18 -0
- data/lib/generators/active_harness/install/templates/requests/support_guard_request.rb +12 -0
- data/lib/generators/active_harness/install/templates/requests/support_request.rb +6 -0
- data/lib/generators/active_harness/install/templates/tools/ls_tool.rb +40 -0
- data/lib/generators/active_harness/install/templates/tools/read_file_tool.rb +43 -0
- data/lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb +7 -0
- data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +5 -0
- data/lib/generators/active_harness/request/templates/request.rb.tt +2 -0
- data/lib/generators/active_harness/tool/templates/tool.rb.tt +22 -0
- data/lib/generators/active_harness/tool/tool_generator.rb +16 -0
- data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +3 -0
- metadata +31 -7
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
@@ -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), " \
|
|
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
|
-
|
|
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
|
-
#
|
|
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,40 @@
|
|
|
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
|
+
# `scope` namespaces the tool (os::ls vs web::ls) and lets you allow or log a whole group.
|
|
9
|
+
# The tool name comes from the class (LsTool => "ls"); override with `tool_name "..."`.
|
|
10
|
+
scope :os
|
|
11
|
+
|
|
12
|
+
# What the model reads when it decides whether to call this tool — write it for the model.
|
|
13
|
+
description "List files in a directory inside the workspace"
|
|
14
|
+
|
|
15
|
+
# An argument the model must provide. `param` only DESCRIBES it to the model; the
|
|
16
|
+
# agent rejects missing or unknown arguments, and the checks below do the real policing.
|
|
17
|
+
param :path, "Directory to list (absolute path inside the workspace)"
|
|
18
|
+
|
|
19
|
+
# Fail closed: PathPolicy raises if a tool using it forgets to define this.
|
|
20
|
+
ALLOWED_PATHS = [Rails.root.join("storage").to_s].freeze
|
|
21
|
+
|
|
22
|
+
# Steps run in order BEFORE #perform. `transform: true` means the return value
|
|
23
|
+
# replaces the input for every later step and for #perform — so after this step the
|
|
24
|
+
# tool only ever sees a canonical path inside ALLOWED_PATHS.
|
|
25
|
+
step :resolve_path, transform: true do |input|
|
|
26
|
+
input.merge(path: safe_path!(input[:path]))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A plain check: refuse with deny!. The text goes back to the model so it can adjust;
|
|
30
|
+
# pass `detail:` for anything that must stay in the logs only.
|
|
31
|
+
step :no_hidden_dirs do |input|
|
|
32
|
+
deny!("hidden dirs are off limits") if input[:path].split("/").any? { |segment| segment.start_with?(".") }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The action itself — always last; its return value is what the model gets back.
|
|
36
|
+
# Cap the output: a huge result floods the model's context.
|
|
37
|
+
def perform(input)
|
|
38
|
+
Dir.children(input[:path]).first(200)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
scope :os # full name: os::read_file (tool name comes from the class)
|
|
10
|
+
description "Read the first lines of a text file inside the workspace"
|
|
11
|
+
|
|
12
|
+
param :path, "Absolute path of the file"
|
|
13
|
+
# Optional arguments: `required: false`; `type:` is :string by default (also :integer, :boolean, ...);
|
|
14
|
+
# `enum: [...]` limits the allowed values.
|
|
15
|
+
param :lines, "How many lines to read (default 50, max 100)", type: :integer, required: false
|
|
16
|
+
|
|
17
|
+
ALLOWED_PATHS = [Rails.root.join("storage").to_s].freeze
|
|
18
|
+
MAX_BYTES = 200_000
|
|
19
|
+
MAX_LINES = 100
|
|
20
|
+
|
|
21
|
+
# Canonicalize first, then check — validating the raw string would miss symlinks and "..".
|
|
22
|
+
step :resolve_path, transform: true do |input|
|
|
23
|
+
input.merge(path: safe_path!(input[:path]))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
step :no_hidden_files do |input|
|
|
27
|
+
deny!("hidden files are off limits") if input[:path].split("/").any? { |segment| segment.start_with?(".") }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# `detail:` is for the logs only — the model just learns that the file is too large.
|
|
31
|
+
step :must_be_a_small_file do |input|
|
|
32
|
+
deny!("not a regular file") unless File.file?(input[:path])
|
|
33
|
+
deny!("file is too large", detail: "#{File.size(input[:path])} bytes") if File.size(input[:path]) > MAX_BYTES
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Anything raised here (other than deny!) becomes a generic "tool failed" for the
|
|
37
|
+
# model; the real error stays in the trace. If a write may have happened but you
|
|
38
|
+
# cannot tell (e.g. a timeout), call unknown_outcome!("...") instead of retrying.
|
|
39
|
+
def perform(input)
|
|
40
|
+
count = [(input[:lines] || 50).to_i, MAX_LINES].min
|
|
41
|
+
File.foreach(input[:path]).first(count).join
|
|
42
|
+
end
|
|
43
|
+
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,22 @@
|
|
|
1
|
+
class <%= class_name %>Tool < ActiveHarness::Tool
|
|
2
|
+
# Namespaces the tool: full name is app::<%= file_name %>. Use :os, :web, :ruby ... per area.
|
|
3
|
+
scope :app
|
|
4
|
+
|
|
5
|
+
# Written for the model: when to call this tool.
|
|
6
|
+
description "Describe what this tool does for the model"
|
|
7
|
+
|
|
8
|
+
# An argument the model provides. It only DESCRIBES the argument — real policing is in the steps.
|
|
9
|
+
param :input, "What the model must pass"
|
|
10
|
+
|
|
11
|
+
# Checks run in order before #perform; refuse with deny!("safe text for the model")
|
|
12
|
+
# (add `detail:` for anything that must stay in the logs). `transform: true` lets a step
|
|
13
|
+
# return a new input for the later steps and #perform.
|
|
14
|
+
# step :allowed do |input|
|
|
15
|
+
# deny!("not allowed") unless context[:user]
|
|
16
|
+
# end
|
|
17
|
+
|
|
18
|
+
# The action itself; its return value goes back to the model. Cap the size of the result.
|
|
19
|
+
def perform(input)
|
|
20
|
+
"result for #{input[:input]}"
|
|
21
|
+
end
|
|
22
|
+
end
|