active_harness 0.2.24 → 0.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/active_harness/agent/models.rb +1 -1
- data/lib/active_harness/agent/output_parser.rb +1 -1
- data/lib/active_harness/agent.rb +2 -2
- data/lib/active_harness/memory/adapter/{file.rb → json_file.rb} +49 -17
- data/lib/active_harness/memory/adapter/postgresql.rb +212 -0
- data/lib/active_harness/memory/adapter/sqlite.rb +233 -0
- data/lib/active_harness/memory.rb +6 -5
- data/lib/active_harness/pipeline/README.md +252 -0
- data/lib/active_harness/pipeline.rb +9 -19
- data/lib/active_harness/result.rb +14 -2
- data/lib/active_harness/tribunal/dsl.rb +3 -3
- data/lib/active_harness/tribunal/processing.rb +1 -1
- data/lib/active_harness/tribunal.rb +13 -2
- data/lib/active_harness.rb +1 -1
- data/lib/generators/active_harness/install/templates/memory/app_memory.rb +9 -9
- data/lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb +1 -1
- data/lib/generators/active_harness/memory/templates/memory.rb.tt +6 -6
- data/lib/generators/active_harness/memory_postgresql/memory_postgresql_generator.rb +23 -0
- data/lib/generators/active_harness/memory_postgresql/templates/create_active_harness_memory_turns.rb.tt +16 -0
- data/lib/generators/active_harness/memory_sqlite/memory_sqlite_generator.rb +23 -0
- data/lib/generators/active_harness/memory_sqlite/templates/create_active_harness_memory_turns.rb.tt +16 -0
- data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +1 -1
- metadata +9 -3
- data/lib/active_harness/memory/json_file.rb +0 -44
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e251d772ac23a015473080cd54ec79dcd1d707b3ca9fa1c8de6132f8152fa873
|
|
4
|
+
data.tar.gz: 68674b1744f7696cb26cc02a505e60e8c6ae117c7f20a118a2e8102c8f0821f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 99ef61764b9c6b7564f1064c191cb89780670c7e24966556f3f52e3fa15b631e492c9c29a3ac7f601249db9515afed291fe2e6d6e333f9fb6c2043dbad22dac1
|
|
7
|
+
data.tar.gz: 8c8609086bc572183398cf20ff0ea2957e8acd8337db9dadfcb8045f1bbdbb7a708b3f3b27273809a717e91196abe4bf272778d521bfa0926338f0df03486e84
|
|
@@ -14,7 +14,7 @@ module ActiveHarness
|
|
|
14
14
|
# Output format for this agent.
|
|
15
15
|
#
|
|
16
16
|
# format :text # default — output is returned as-is
|
|
17
|
-
# format :json # output is parsed; result.
|
|
17
|
+
# format :json # output is parsed; result.processed is a Ruby Hash/Array
|
|
18
18
|
def format(type)
|
|
19
19
|
unless %i[text json].include?(type)
|
|
20
20
|
raise ArgumentError, "Unknown format :#{type}. Valid values: :text, :json"
|
|
@@ -39,7 +39,7 @@ module ActiveHarness
|
|
|
39
39
|
begin
|
|
40
40
|
parsed = JSON.parse(clean)
|
|
41
41
|
|
|
42
|
-
# :after_parse — return value replaces
|
|
42
|
+
# :after_parse — return value replaces processed result stored in Result
|
|
43
43
|
transform_hook(:after_parse, parsed)
|
|
44
44
|
rescue JSON::ParserError => e
|
|
45
45
|
# :parse_error — if hook returns non-nil, it is used as fallback value
|
data/lib/active_harness/agent.rb
CHANGED
|
@@ -140,13 +140,13 @@ module ActiveHarness
|
|
|
140
140
|
|
|
141
141
|
def build_result(response, entry, attempts, elapsed)
|
|
142
142
|
raw = response[:content]
|
|
143
|
-
|
|
143
|
+
processed = parse_output(raw)
|
|
144
144
|
usage = response[:usage]
|
|
145
145
|
|
|
146
146
|
Result.new(
|
|
147
147
|
input: @input,
|
|
148
148
|
output: raw,
|
|
149
|
-
|
|
149
|
+
processed: processed,
|
|
150
150
|
system_prompt: @system_prompt,
|
|
151
151
|
provider: entry[:provider],
|
|
152
152
|
model: entry[:model],
|
|
@@ -4,7 +4,7 @@ require "fileutils"
|
|
|
4
4
|
module ActiveHarness
|
|
5
5
|
class Memory
|
|
6
6
|
module Adapter
|
|
7
|
-
#
|
|
7
|
+
# File-backed memory adapter — stores turns as JSON on disk.
|
|
8
8
|
#
|
|
9
9
|
# Each session is stored in one file:
|
|
10
10
|
# <path>/<session_id>.json (no namespace)
|
|
@@ -19,21 +19,21 @@ module ActiveHarness
|
|
|
19
19
|
# storage_size — max turns kept in file (default: 1000)
|
|
20
20
|
# eviction_percent — % of oldest turns to drop (default: 10)
|
|
21
21
|
# on_trim — Proc called with trimmed turns (default: nil)
|
|
22
|
-
class
|
|
23
|
-
DEFAULT_PATH
|
|
24
|
-
DEFAULT_STORAGE_SIZE
|
|
22
|
+
class JsonFile < Base
|
|
23
|
+
DEFAULT_PATH = "storage/ai/memory"
|
|
24
|
+
DEFAULT_STORAGE_SIZE = 1000
|
|
25
25
|
DEFAULT_TRIM_PERCENT = 10
|
|
26
26
|
|
|
27
27
|
def initialize(opts = {})
|
|
28
|
-
@path
|
|
29
|
-
@filename_opt
|
|
30
|
-
@pretty
|
|
31
|
-
@compact
|
|
32
|
-
@encoding
|
|
33
|
-
@storage_size
|
|
28
|
+
@path = opts.fetch(:path, DEFAULT_PATH)
|
|
29
|
+
@filename_opt = opts[:filename]
|
|
30
|
+
@pretty = opts.fetch(:pretty, false)
|
|
31
|
+
@compact = opts.fetch(:compact, false)
|
|
32
|
+
@encoding = opts.fetch(:encoding, "UTF-8")
|
|
33
|
+
@storage_size = opts.fetch(:storage_size, DEFAULT_STORAGE_SIZE)
|
|
34
34
|
@trim_percent = opts.fetch(:eviction_percent, DEFAULT_TRIM_PERCENT)
|
|
35
|
-
@on_trim
|
|
36
|
-
@namespace
|
|
35
|
+
@on_trim = opts[:on_trim]
|
|
36
|
+
@namespace = opts[:namespace]
|
|
37
37
|
|
|
38
38
|
@session_id = nil
|
|
39
39
|
@turns = []
|
|
@@ -55,13 +55,12 @@ module ActiveHarness
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def close
|
|
58
|
-
#
|
|
58
|
+
# Writes immediately on each write — nothing to flush.
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def delete
|
|
62
62
|
path = file_path
|
|
63
63
|
::FileUtils.rm_f(path)
|
|
64
|
-
# remove parent dir only if it's a namespace dir and now empty
|
|
65
64
|
dir = ::File.dirname(path)
|
|
66
65
|
if @namespace && Dir.exist?(dir) && Dir.empty?(dir)
|
|
67
66
|
Dir.rmdir(dir)
|
|
@@ -77,8 +76,6 @@ module ActiveHarness
|
|
|
77
76
|
name = resolve_filename
|
|
78
77
|
if @namespace
|
|
79
78
|
::File.join(@path, @session_id.to_s, "#{@namespace}.json")
|
|
80
|
-
elsif @filename_opt
|
|
81
|
-
::File.join(@path, name)
|
|
82
79
|
else
|
|
83
80
|
::File.join(@path, name)
|
|
84
81
|
end
|
|
@@ -100,7 +97,6 @@ module ActiveHarness
|
|
|
100
97
|
data = JSON.parse(raw, symbolize_names: true)
|
|
101
98
|
turns = Array(data[:turns])
|
|
102
99
|
|
|
103
|
-
# Normalise compact format (q/a) to full format (request/response)
|
|
104
100
|
turns.map do |t|
|
|
105
101
|
if t.key?(:q)
|
|
106
102
|
{ request: t[:q], response: t[:a] }
|
|
@@ -137,5 +133,41 @@ module ActiveHarness
|
|
|
137
133
|
end
|
|
138
134
|
end
|
|
139
135
|
end
|
|
136
|
+
|
|
137
|
+
# Convenience Memory subclass for file-backed storage.
|
|
138
|
+
#
|
|
139
|
+
# file_name: replaces session_id — may contain slashes to create
|
|
140
|
+
# subdirectories under storage_path, e.g. "users/42/chat"
|
|
141
|
+
# Final file is always <storage_path>/<file_name>.json
|
|
142
|
+
# storage_path: base directory (default: "storage/ai/memory")
|
|
143
|
+
#
|
|
144
|
+
# Path traversal is rejected: segments equal to "." or ".." or containing
|
|
145
|
+
# null bytes raise ArgumentError before any file I/O happens.
|
|
146
|
+
# Missing directories are created automatically on the first write.
|
|
147
|
+
class JsonFile < Memory
|
|
148
|
+
def initialize(file_name:, storage_path: Adapter::JsonFile::DEFAULT_PATH, **opts)
|
|
149
|
+
super(
|
|
150
|
+
session_id: sanitize!(file_name),
|
|
151
|
+
adapter: Adapter::JsonFile.new(opts.merge(path: storage_path)),
|
|
152
|
+
**opts
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def sanitize!(raw)
|
|
159
|
+
parts = raw.to_s.split("/").map(&:strip).reject(&:empty?)
|
|
160
|
+
raise ArgumentError, "file_name must not be empty" if parts.empty?
|
|
161
|
+
|
|
162
|
+
parts.each do |part|
|
|
163
|
+
if part == ".." || part == "." || part.include?("\0")
|
|
164
|
+
raise ArgumentError, "Invalid file_name segment: #{part.inspect}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
parts.last.sub!(/\.json\z/i, "")
|
|
169
|
+
parts.join("/")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
140
172
|
end
|
|
141
173
|
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
class Memory
|
|
5
|
+
module Adapter
|
|
6
|
+
# PostgreSQL-backed memory adapter.
|
|
7
|
+
#
|
|
8
|
+
# Requires the 'pg' gem — add it to your Gemfile yourself:
|
|
9
|
+
# gem 'pg'
|
|
10
|
+
#
|
|
11
|
+
# Connection options (one of):
|
|
12
|
+
# connection: — a PG::Connection instance you manage
|
|
13
|
+
# url: — connection string; adapter opens and closes the connection
|
|
14
|
+
# host:, port:, dbname:, user:, password: — adapter opens and closes the connection
|
|
15
|
+
#
|
|
16
|
+
# Storage options:
|
|
17
|
+
# table_name: — default "active_harness_memory_turns"
|
|
18
|
+
# storage_size: — max turns per session kept in the table (default 1000)
|
|
19
|
+
# eviction_percent — % of oldest turns to drop when limit is hit (default 10)
|
|
20
|
+
# on_trim: — Proc called with evicted turns
|
|
21
|
+
# namespace: — isolates turns within a session
|
|
22
|
+
class Postgresql < Base
|
|
23
|
+
DEFAULT_TABLE = "active_harness_memory_turns"
|
|
24
|
+
DEFAULT_STORAGE_SIZE = 1000
|
|
25
|
+
DEFAULT_TRIM_PERCENT = 10
|
|
26
|
+
|
|
27
|
+
TABLE_NAME_RE = /\A[a-zA-Z_][a-zA-Z0-9_.]*\z/
|
|
28
|
+
|
|
29
|
+
def initialize(opts = {})
|
|
30
|
+
@table = opts.fetch(:table_name, DEFAULT_TABLE).to_s
|
|
31
|
+
@storage_size = opts.fetch(:storage_size, DEFAULT_STORAGE_SIZE)
|
|
32
|
+
@trim_percent = opts.fetch(:eviction_percent, DEFAULT_TRIM_PERCENT)
|
|
33
|
+
@on_trim = opts[:on_trim]
|
|
34
|
+
@namespace = opts[:namespace]
|
|
35
|
+
|
|
36
|
+
unless @table.match?(TABLE_NAME_RE)
|
|
37
|
+
raise ArgumentError, "Invalid table_name: #{@table.inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Connection source — one of: instance, url, keyword args.
|
|
41
|
+
@borrowed_conn = opts[:connection]
|
|
42
|
+
@conn_url = opts[:url]
|
|
43
|
+
@conn_kwargs = opts.slice(:host, :port, :dbname, :user, :password)
|
|
44
|
+
|
|
45
|
+
@owned_conn = nil
|
|
46
|
+
@session_id = nil
|
|
47
|
+
@turns = []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def open(session_id)
|
|
51
|
+
@session_id = session_id
|
|
52
|
+
ensure_connection!
|
|
53
|
+
@turns = fetch_turns
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read
|
|
57
|
+
@turns.dup
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def write(turn)
|
|
61
|
+
insert_turn(turn)
|
|
62
|
+
@turns << turn
|
|
63
|
+
trim_if_needed!
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def close
|
|
67
|
+
if @owned_conn
|
|
68
|
+
@owned_conn.close rescue nil
|
|
69
|
+
@owned_conn = nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def delete
|
|
74
|
+
conn.exec_params(
|
|
75
|
+
"DELETE FROM #{@table} " \
|
|
76
|
+
"WHERE session_id = $1 AND (namespace IS NOT DISTINCT FROM $2)",
|
|
77
|
+
[@session_id, @namespace]
|
|
78
|
+
)
|
|
79
|
+
@turns = []
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# -----------------------------------------------------------------------
|
|
83
|
+
private
|
|
84
|
+
# -----------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def conn
|
|
87
|
+
@borrowed_conn || @owned_conn ||
|
|
88
|
+
raise("PostgreSQL connection not open — call open(session_id) first")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def ensure_connection!
|
|
92
|
+
return if @borrowed_conn || @owned_conn
|
|
93
|
+
|
|
94
|
+
load_pg!
|
|
95
|
+
|
|
96
|
+
@owned_conn =
|
|
97
|
+
if @conn_url
|
|
98
|
+
PG.connect(@conn_url)
|
|
99
|
+
elsif @conn_kwargs.any?
|
|
100
|
+
PG.connect(**@conn_kwargs)
|
|
101
|
+
else
|
|
102
|
+
raise ArgumentError,
|
|
103
|
+
"PostgreSQL adapter requires one of: connection:, url:, " \
|
|
104
|
+
"or connection keyword args (host:, port:, dbname:, user:, password:)"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def load_pg!
|
|
109
|
+
require "pg"
|
|
110
|
+
rescue LoadError
|
|
111
|
+
raise LoadError,
|
|
112
|
+
"The 'pg' gem is required for the PostgreSQL memory adapter. " \
|
|
113
|
+
"Add it to your Gemfile: gem 'pg'"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def fetch_turns
|
|
117
|
+
result = conn.exec_params(
|
|
118
|
+
"SELECT request, response, meta " \
|
|
119
|
+
"FROM #{@table} " \
|
|
120
|
+
"WHERE session_id = $1 AND (namespace IS NOT DISTINCT FROM $2) " \
|
|
121
|
+
"ORDER BY id ASC",
|
|
122
|
+
[@session_id, @namespace]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
result.map do |row|
|
|
126
|
+
turn = { request: row["request"], response: row["response"] }
|
|
127
|
+
meta = JSON.parse(row["meta"] || "{}", symbolize_names: true)
|
|
128
|
+
meta.empty? ? turn : turn.merge(meta)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def insert_turn(turn)
|
|
133
|
+
meta = turn.reject { |k, _| k == :request || k == :response }
|
|
134
|
+
|
|
135
|
+
conn.exec_params(
|
|
136
|
+
"INSERT INTO #{@table} (session_id, namespace, request, response, meta) " \
|
|
137
|
+
"VALUES ($1, $2, $3, $4, $5::jsonb)",
|
|
138
|
+
[
|
|
139
|
+
@session_id,
|
|
140
|
+
@namespace,
|
|
141
|
+
turn[:request].to_s,
|
|
142
|
+
turn[:response].to_s,
|
|
143
|
+
JSON.generate(meta)
|
|
144
|
+
]
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def trim_if_needed!
|
|
149
|
+
return unless @storage_size
|
|
150
|
+
return if @turns.size <= @storage_size
|
|
151
|
+
|
|
152
|
+
to_delete = [@turns.size * @trim_percent / 100, 1].max
|
|
153
|
+
|
|
154
|
+
if @on_trim
|
|
155
|
+
trimmed = @turns.first(to_delete)
|
|
156
|
+
@on_trim.call(trimmed)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
conn.exec_params(
|
|
160
|
+
"DELETE FROM #{@table} WHERE id IN (" \
|
|
161
|
+
" SELECT id FROM #{@table} " \
|
|
162
|
+
" WHERE session_id = $1 AND (namespace IS NOT DISTINCT FROM $2) " \
|
|
163
|
+
" ORDER BY id ASC LIMIT $3" \
|
|
164
|
+
")",
|
|
165
|
+
[@session_id, @namespace, to_delete]
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
@turns.shift(to_delete)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Convenience Memory subclass for PostgreSQL-backed storage.
|
|
174
|
+
#
|
|
175
|
+
# Requires the 'pg' gem installed by the application:
|
|
176
|
+
# gem 'pg'
|
|
177
|
+
#
|
|
178
|
+
# Usage — plain Ruby (adapter owns the connection):
|
|
179
|
+
# mem = ActiveHarness::Memory::Postgresql.new(
|
|
180
|
+
# session_id: "user_42",
|
|
181
|
+
# url: ENV["DATABASE_URL"],
|
|
182
|
+
# depth: 10
|
|
183
|
+
# )
|
|
184
|
+
# mem.load
|
|
185
|
+
# # ... use ...
|
|
186
|
+
# mem.close
|
|
187
|
+
#
|
|
188
|
+
# Usage — Rails (borrow the AR raw connection):
|
|
189
|
+
# mem = ActiveHarness::Memory::Postgresql.new(
|
|
190
|
+
# session_id: "user_42",
|
|
191
|
+
# connection: ActiveRecord::Base.connection.raw_connection
|
|
192
|
+
# )
|
|
193
|
+
class Postgresql < Memory
|
|
194
|
+
def initialize(session_id:, namespace: nil, on_trim: nil, **opts)
|
|
195
|
+
mem_keys = %i[depth enabled read_only async]
|
|
196
|
+
mem_opts = opts.slice(*mem_keys)
|
|
197
|
+
pg_opts = opts.reject { |k, _| mem_keys.include?(k) }
|
|
198
|
+
|
|
199
|
+
pg_opts[:namespace] = namespace if namespace
|
|
200
|
+
pg_opts[:on_trim] = on_trim if on_trim
|
|
201
|
+
|
|
202
|
+
super(
|
|
203
|
+
session_id: session_id,
|
|
204
|
+
adapter: Adapter::Postgresql.new(pg_opts),
|
|
205
|
+
namespace: namespace,
|
|
206
|
+
on_trim: on_trim,
|
|
207
|
+
**mem_opts
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
class Memory
|
|
5
|
+
module Adapter
|
|
6
|
+
# SQLite-backed memory adapter.
|
|
7
|
+
#
|
|
8
|
+
# Requires the 'sqlite3' gem — add it to your Gemfile yourself:
|
|
9
|
+
# gem 'sqlite3'
|
|
10
|
+
#
|
|
11
|
+
# Connection options (one of):
|
|
12
|
+
# database: — path to the SQLite file; adapter opens and closes the connection
|
|
13
|
+
# use ":memory:" for an in-process, non-persistent database
|
|
14
|
+
# connection: — an existing SQLite3::Database instance you manage
|
|
15
|
+
#
|
|
16
|
+
# Storage options:
|
|
17
|
+
# table_name: — default "active_harness_memory_turns"
|
|
18
|
+
# storage_size: — max turns per session kept in the table (default 1000)
|
|
19
|
+
# eviction_percent — % of oldest turns to drop when limit is hit (default 10)
|
|
20
|
+
# on_trim: — Proc called with evicted turns
|
|
21
|
+
# namespace: — isolates turns within a session
|
|
22
|
+
class Sqlite < Base
|
|
23
|
+
DEFAULT_TABLE = "active_harness_memory_turns"
|
|
24
|
+
DEFAULT_STORAGE_SIZE = 1000
|
|
25
|
+
DEFAULT_TRIM_PERCENT = 10
|
|
26
|
+
|
|
27
|
+
TABLE_NAME_RE = /\A[a-zA-Z_][a-zA-Z0-9_.]*\z/
|
|
28
|
+
|
|
29
|
+
def initialize(opts = {})
|
|
30
|
+
@table = opts.fetch(:table_name, DEFAULT_TABLE).to_s
|
|
31
|
+
@storage_size = opts.fetch(:storage_size, DEFAULT_STORAGE_SIZE)
|
|
32
|
+
@trim_percent = opts.fetch(:eviction_percent, DEFAULT_TRIM_PERCENT)
|
|
33
|
+
@on_trim = opts[:on_trim]
|
|
34
|
+
@namespace = opts[:namespace]
|
|
35
|
+
|
|
36
|
+
unless @table.match?(TABLE_NAME_RE)
|
|
37
|
+
raise ArgumentError, "Invalid table_name: #{@table.inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@borrowed_conn = opts[:connection]
|
|
41
|
+
@db_path = opts[:database]
|
|
42
|
+
@owned_conn = nil
|
|
43
|
+
@session_id = nil
|
|
44
|
+
@turns = []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def open(session_id)
|
|
48
|
+
@session_id = session_id
|
|
49
|
+
ensure_connection!
|
|
50
|
+
@turns = fetch_turns
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def read
|
|
54
|
+
@turns.dup
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def write(turn)
|
|
58
|
+
insert_turn(turn)
|
|
59
|
+
@turns << turn
|
|
60
|
+
trim_if_needed!
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def close
|
|
64
|
+
if @owned_conn
|
|
65
|
+
@owned_conn.close rescue nil
|
|
66
|
+
@owned_conn = nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def delete
|
|
71
|
+
db.execute(
|
|
72
|
+
"DELETE FROM #{@table} WHERE session_id = ? AND (namespace IS ?)",
|
|
73
|
+
[@session_id, @namespace]
|
|
74
|
+
)
|
|
75
|
+
@turns = []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# -----------------------------------------------------------------------
|
|
79
|
+
private
|
|
80
|
+
# -----------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def db
|
|
83
|
+
@borrowed_conn || @owned_conn ||
|
|
84
|
+
raise("SQLite connection not open — call open(session_id) first")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def ensure_connection!
|
|
88
|
+
return if @borrowed_conn || @owned_conn
|
|
89
|
+
|
|
90
|
+
load_sqlite3!
|
|
91
|
+
|
|
92
|
+
unless @db_path
|
|
93
|
+
raise ArgumentError,
|
|
94
|
+
"SQLite adapter requires database: (file path or ':memory:')"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
conn = SQLite3::Database.new(@db_path)
|
|
98
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
99
|
+
@owned_conn = conn
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def load_sqlite3!
|
|
103
|
+
require "sqlite3"
|
|
104
|
+
rescue LoadError
|
|
105
|
+
raise LoadError,
|
|
106
|
+
"The 'sqlite3' gem is required for the SQLite memory adapter. " \
|
|
107
|
+
"Add it to your Gemfile: gem 'sqlite3'"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fetch_turns
|
|
111
|
+
rows = db.execute(
|
|
112
|
+
"SELECT request, response, meta " \
|
|
113
|
+
"FROM #{@table} " \
|
|
114
|
+
"WHERE session_id = ? AND (namespace IS ?) " \
|
|
115
|
+
"ORDER BY id ASC",
|
|
116
|
+
[@session_id, @namespace]
|
|
117
|
+
)
|
|
118
|
+
rows.map do |row|
|
|
119
|
+
# row is Array (default) or Hash (results_as_hash=true) — handle both
|
|
120
|
+
req, resp, meta_str =
|
|
121
|
+
row.is_a?(Hash) ? row.values_at("request", "response", "meta") : row
|
|
122
|
+
turn = { request: req, response: resp }
|
|
123
|
+
meta = JSON.parse(meta_str || "{}", symbolize_names: true)
|
|
124
|
+
meta.empty? ? turn : turn.merge(meta)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def insert_turn(turn)
|
|
129
|
+
meta = turn.reject { |k, _| k == :request || k == :response }
|
|
130
|
+
db.execute(
|
|
131
|
+
"INSERT INTO #{@table} (session_id, namespace, request, response, meta) " \
|
|
132
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
133
|
+
[
|
|
134
|
+
@session_id,
|
|
135
|
+
@namespace,
|
|
136
|
+
turn[:request].to_s,
|
|
137
|
+
turn[:response].to_s,
|
|
138
|
+
JSON.generate(meta)
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def trim_if_needed!
|
|
144
|
+
return unless @storage_size
|
|
145
|
+
return if @turns.size <= @storage_size
|
|
146
|
+
|
|
147
|
+
to_delete = [@turns.size * @trim_percent / 100, 1].max
|
|
148
|
+
|
|
149
|
+
if @on_trim
|
|
150
|
+
trimmed = @turns.first(to_delete)
|
|
151
|
+
@on_trim.call(trimmed)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
db.execute(
|
|
155
|
+
"DELETE FROM #{@table} WHERE id IN (" \
|
|
156
|
+
" SELECT id FROM #{@table} " \
|
|
157
|
+
" WHERE session_id = ? AND (namespace IS ?) " \
|
|
158
|
+
" ORDER BY id ASC LIMIT ?" \
|
|
159
|
+
")",
|
|
160
|
+
[@session_id, @namespace, to_delete]
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@turns.shift(to_delete)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Convenience Memory subclass for SQLite-backed storage.
|
|
169
|
+
#
|
|
170
|
+
# Usage — plain Ruby (adapter owns the connection):
|
|
171
|
+
# mem = ActiveHarness::Memory::Sqlite.new(
|
|
172
|
+
# session_id: "user_42",
|
|
173
|
+
# database: "storage/ai/memory.sqlite3",
|
|
174
|
+
# depth: 10
|
|
175
|
+
# )
|
|
176
|
+
# mem.load
|
|
177
|
+
# # ... use ...
|
|
178
|
+
# mem.close
|
|
179
|
+
#
|
|
180
|
+
# Usage — Rails (borrow AR raw connection for SQLite3 adapter):
|
|
181
|
+
# mem = ActiveHarness::Memory::Sqlite.new(
|
|
182
|
+
# session_id: "user_42",
|
|
183
|
+
# connection: ActiveRecord::Base.connection.raw_connection
|
|
184
|
+
# )
|
|
185
|
+
#
|
|
186
|
+
# Plain Ruby schema setup (run once before first use):
|
|
187
|
+
# ActiveHarness::Memory::Sqlite.create_schema!("storage/ai/memory.sqlite3")
|
|
188
|
+
class Sqlite < Memory
|
|
189
|
+
# SQL to create the schema — run once before first use in plain Ruby.
|
|
190
|
+
SCHEMA_SQL = <<~SQL.freeze
|
|
191
|
+
CREATE TABLE IF NOT EXISTS active_harness_memory_turns (
|
|
192
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
193
|
+
session_id TEXT NOT NULL,
|
|
194
|
+
namespace TEXT,
|
|
195
|
+
request TEXT NOT NULL,
|
|
196
|
+
response TEXT NOT NULL,
|
|
197
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
198
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
199
|
+
);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_ah_memory_turns_session
|
|
201
|
+
ON active_harness_memory_turns (session_id, namespace, id);
|
|
202
|
+
SQL
|
|
203
|
+
|
|
204
|
+
# Create the schema on an existing database or a file path.
|
|
205
|
+
# Safe to call multiple times — uses CREATE TABLE IF NOT EXISTS.
|
|
206
|
+
def self.create_schema!(db_or_path)
|
|
207
|
+
require "sqlite3"
|
|
208
|
+
conn = db_or_path.is_a?(String) ? SQLite3::Database.new(db_or_path) : db_or_path
|
|
209
|
+
SCHEMA_SQL.split(";").map(&:strip).reject(&:empty?).each { |sql| conn.execute(sql) }
|
|
210
|
+
rescue LoadError
|
|
211
|
+
raise LoadError,
|
|
212
|
+
"The 'sqlite3' gem is required. Add it to your Gemfile: gem 'sqlite3'"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def initialize(session_id:, namespace: nil, on_trim: nil, **opts)
|
|
216
|
+
mem_keys = %i[depth enabled read_only async]
|
|
217
|
+
mem_opts = opts.slice(*mem_keys)
|
|
218
|
+
sq_opts = opts.reject { |k, _| mem_keys.include?(k) }
|
|
219
|
+
|
|
220
|
+
sq_opts[:namespace] = namespace if namespace
|
|
221
|
+
sq_opts[:on_trim] = on_trim if on_trim
|
|
222
|
+
|
|
223
|
+
super(
|
|
224
|
+
session_id: session_id,
|
|
225
|
+
adapter: Adapter::Sqlite.new(sq_opts),
|
|
226
|
+
namespace: namespace,
|
|
227
|
+
on_trim: on_trim,
|
|
228
|
+
**mem_opts
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require_relative "memory/adapter/base"
|
|
2
|
-
require_relative "memory/adapter/
|
|
3
|
-
require_relative "memory/
|
|
2
|
+
require_relative "memory/adapter/json_file"
|
|
3
|
+
require_relative "memory/adapter/postgresql"
|
|
4
|
+
require_relative "memory/adapter/sqlite"
|
|
4
5
|
|
|
5
6
|
module ActiveHarness
|
|
6
7
|
# Conversational memory for agents.
|
|
@@ -55,7 +56,7 @@ module ActiveHarness
|
|
|
55
56
|
#
|
|
56
57
|
class Memory
|
|
57
58
|
ADAPTERS = {
|
|
58
|
-
|
|
59
|
+
json_file: ->(**opts) { Adapter::JsonFile.new(**opts) }
|
|
59
60
|
}.freeze
|
|
60
61
|
|
|
61
62
|
attr_reader :session_id
|
|
@@ -65,7 +66,7 @@ module ActiveHarness
|
|
|
65
66
|
# -------------------------------------------------------------------------
|
|
66
67
|
# session_id — required; uniquely identifies this conversation
|
|
67
68
|
# depth — how many past turns to inject into messages (nil = all)
|
|
68
|
-
# adapter — :
|
|
69
|
+
# adapter — :json_file (default), or an adapter instance
|
|
69
70
|
# enabled — false disables all reads and writes (no-op mode)
|
|
70
71
|
# read_only — true: load history but never write new turns
|
|
71
72
|
# namespace — isolates history per-agent within a session
|
|
@@ -75,7 +76,7 @@ module ActiveHarness
|
|
|
75
76
|
def initialize(
|
|
76
77
|
session_id:,
|
|
77
78
|
depth: nil,
|
|
78
|
-
adapter: :
|
|
79
|
+
adapter: :json_file,
|
|
79
80
|
enabled: true,
|
|
80
81
|
read_only: false,
|
|
81
82
|
namespace: nil,
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# Pipeline
|
|
2
|
+
|
|
3
|
+
A pipeline chains multiple agents and tribunals into a sequential workflow.
|
|
4
|
+
Each step receives the current payload, can transform it, and can stop the pipeline early.
|
|
5
|
+
|
|
6
|
+
## Basic usage
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
class SupportPipeline < ActiveHarness::Pipeline
|
|
10
|
+
step :translate, TranslationAgent
|
|
11
|
+
|
|
12
|
+
step :injection_guard do
|
|
13
|
+
use InjectionGuardAgent
|
|
14
|
+
stop_if ->(result) { result.processed["detected"] == true }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
step :safety_tribunal do
|
|
18
|
+
use SafetyTribunal
|
|
19
|
+
stop_if ->(result) { result.verdict == false }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
pipeline = SupportPipeline.new(input: "Hello", context: { user_id: 1 })
|
|
24
|
+
pipeline.call
|
|
25
|
+
|
|
26
|
+
pipeline.output # => final payload string (nil if stopped)
|
|
27
|
+
pipeline.stopped? # => false
|
|
28
|
+
pipeline.step_results # => { translate: <Result>, injection_guard: <Result>, ... }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Step types
|
|
32
|
+
|
|
33
|
+
There are two kinds of classes a step can use.
|
|
34
|
+
|
|
35
|
+
**Agent step** — runs the agent, takes `result.output` as the new payload:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
step :translate, TranslationAgent
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Tribunal step** — runs the tribunal, returns a `Result` with `processed["verdict"]`.
|
|
42
|
+
Payload is never updated by a tribunal step (it always has `stop_if`):
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
step :safety_tribunal do
|
|
46
|
+
use SafetyTribunal
|
|
47
|
+
stop_if ->(result) { result.processed["verdict"] == false }
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Payload propagation
|
|
52
|
+
|
|
53
|
+
The payload starts as the value passed to `input:` and flows through the steps:
|
|
54
|
+
|
|
55
|
+
| Condition | Payload after step |
|
|
56
|
+
|-----------|--------------------|
|
|
57
|
+
| Agent step, no `stop_if` | Updated to `result.output` |
|
|
58
|
+
| Agent step with `stop_if` | Unchanged (guard step) |
|
|
59
|
+
| Tribunal step | Unchanged |
|
|
60
|
+
|
|
61
|
+
After each step the result is also stored in `context[step_name]`,
|
|
62
|
+
so later steps can read earlier results via `@context[:translate]` etc.
|
|
63
|
+
|
|
64
|
+
## Stopping the pipeline
|
|
65
|
+
|
|
66
|
+
Any step can stop the pipeline by defining `stop_if`:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
step :injection_guard do
|
|
70
|
+
use InjectionGuardAgent
|
|
71
|
+
stop_if ->(result) { result.processed["detected"] == true }
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
When the condition is true:
|
|
76
|
+
- remaining steps are skipped
|
|
77
|
+
- `pipeline.stopped?` returns `true`
|
|
78
|
+
- `pipeline.stopped_at` holds the step name
|
|
79
|
+
- `pipeline.output` is `nil`
|
|
80
|
+
|
|
81
|
+
## Events and hooks
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
class SupportPipeline < ActiveHarness::Pipeline
|
|
85
|
+
on_agent_event do |event, result| ... end # fires for every agent inside
|
|
86
|
+
on_tribunal_event do |event, verdict| ... end # fires for every tribunal inside
|
|
87
|
+
on_pipeline_event do |event, *args| ... end # :before_step, :after_step, :stopped, :complete
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Runtime streams can be passed at construction time:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
SupportPipeline.new(
|
|
95
|
+
input: "...",
|
|
96
|
+
streams: { token: token_lambda, agent: agent_lambda }
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Memory
|
|
101
|
+
|
|
102
|
+
A memory object can be attached to the pipeline. It is loaded before the first step
|
|
103
|
+
and a record is written after successful completion (skipped if the pipeline stops early):
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
mem = ActiveHarness::Memory::JsonFile.new(file_name: "session_42")
|
|
107
|
+
|
|
108
|
+
SupportPipeline.new(input: "...", memory: mem).call
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Proposal: universal step interface
|
|
114
|
+
|
|
115
|
+
Currently `Pipeline::Step` special-cases two concrete classes: `Agent` and `Tribunal`.
|
|
116
|
+
This section explores making the pipeline open to any entity — a plain Ruby object,
|
|
117
|
+
a lambda, a nested pipeline, an HTTP call, a cache lookup — with no inheritance required.
|
|
118
|
+
|
|
119
|
+
The core question is: **what must a step return so the pipeline can drive it?**
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
### Option A — Duck-type protocol (minimal change)
|
|
124
|
+
|
|
125
|
+
Define a lightweight protocol. Any object that satisfies it can be a step:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# Contract: class responds to .new(input:, context:, params:, streams:)
|
|
129
|
+
# Instance responds to .call → returns an object with:
|
|
130
|
+
# .output — new payload (String or any value); nil keeps payload unchanged
|
|
131
|
+
# .stop? — true signals the pipeline to halt (replaces stop_if in step DSL)
|
|
132
|
+
|
|
133
|
+
class UppercaseStep
|
|
134
|
+
def initialize(input:, **); @input = input; end
|
|
135
|
+
|
|
136
|
+
def call
|
|
137
|
+
Pipeline::StepResult.new(output: @input.upcase)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
step :upcase, UppercaseStep
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`Pipeline::StepResult` would be a tiny value object:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
Pipeline::StepResult = Struct.new(:output, :stop, keyword_init: true) do
|
|
148
|
+
def stop? = stop
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Pros:** almost no change to existing code; agents and tribunals get thin adapters.
|
|
153
|
+
**Cons:** every custom step must construct `StepResult`; slightly more boilerplate.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
### Option B — Callable (lambda / proc) as a step
|
|
158
|
+
|
|
159
|
+
Allow any `Proc`/`lambda` directly, without a wrapper class:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
step :sanitize, ->(payload, ctx) { payload.strip }
|
|
163
|
+
|
|
164
|
+
step :length_guard, ->(payload, ctx) {
|
|
165
|
+
payload.length > 1000 ? Pipeline::Stop : payload
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Return value rules:
|
|
170
|
+
- any value other than `Pipeline::Stop` → becomes new payload
|
|
171
|
+
- `Pipeline::Stop` (or `Pipeline::Stop.new(reason)`) → halts the pipeline
|
|
172
|
+
|
|
173
|
+
**Pros:** perfect for simple transformations and guards; zero boilerplate.
|
|
174
|
+
**Cons:** no access to `params:` or `streams:` without enlarging the lambda signature;
|
|
175
|
+
harder to test in isolation.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
### Option C — Rack-style env hash
|
|
180
|
+
|
|
181
|
+
Each step receives and returns a single hash (`env`), similar to Rack middleware:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
# env keys: :input, :output, :context, :params, :streams
|
|
185
|
+
# Return env to continue, return Pipeline::Stop to halt.
|
|
186
|
+
|
|
187
|
+
class UppercaseStep
|
|
188
|
+
def call(env)
|
|
189
|
+
env.merge(output: env[:input].upcase)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
class LengthGuard
|
|
194
|
+
def call(env)
|
|
195
|
+
env[:input].length > 1000 ? Pipeline::Stop.new("too long") : env
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Steps become stateless (no `initialize`) — a single instance can be reused:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
UPCASE = UppercaseStep.new
|
|
204
|
+
|
|
205
|
+
step :upcase, UPCASE
|
|
206
|
+
step :length_guard, LengthGuard.new
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Pros:** stateless, composable, easy to test (`call(env)` in one line); nested
|
|
210
|
+
pipelines become trivial — a pipeline is just another object with `call(env)`.
|
|
211
|
+
**Cons:** largest departure from the current API; requires migrating Agent/Tribunal wrappers.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
### Option D — `Pipeline::Callable` module (explicit contract)
|
|
216
|
+
|
|
217
|
+
A module that documents the contract and provides `StepResult` helpers:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
class EnrichStep
|
|
221
|
+
include ActiveHarness::Pipeline::Callable # documents intent, no magic
|
|
222
|
+
|
|
223
|
+
def initialize(input:, context:, **); @input = input; @context = context; end
|
|
224
|
+
|
|
225
|
+
def call
|
|
226
|
+
data = ExternalService.fetch(@context[:user_id])
|
|
227
|
+
result(output: "#{@input} [enriched: #{data}]") # helper from Callable
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Agents and Tribunals include `Callable` automatically, so they work as before.
|
|
233
|
+
Any plain class can opt in with one `include`.
|
|
234
|
+
|
|
235
|
+
**Pros:** clear opt-in contract; helpers reduce boilerplate; IDE-friendly.
|
|
236
|
+
**Cons:** still requires `include`; doesn't help lambdas or nested pipelines directly.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
### Comparison
|
|
241
|
+
|
|
242
|
+
| | No inheritance | Lambda support | Nested pipeline | Migration cost |
|
|
243
|
+
|---|---|---|---|---|
|
|
244
|
+
| **A — duck type** | yes | with wrapper | yes | low |
|
|
245
|
+
| **B — lambda** | yes | native | no | low |
|
|
246
|
+
| **C — Rack env** | yes | yes (`.call`) | yes (trivially) | high |
|
|
247
|
+
| **D — module** | yes (opt-in) | with wrapper | yes | low |
|
|
248
|
+
|
|
249
|
+
**Recommendation:** start with **B** (lambda steps) for simple cases and **A** (duck-type
|
|
250
|
+
protocol + `StepResult`) for structured steps — both require minimal changes to the
|
|
251
|
+
existing engine. Option C is the most powerful but is a bigger refactor; consider it
|
|
252
|
+
if nested pipelines or stateless reuse become a real need.
|
|
@@ -7,7 +7,7 @@ module ActiveHarness
|
|
|
7
7
|
# class SupportPipeline < ActiveHarness::Pipeline
|
|
8
8
|
# step :injection_guard do
|
|
9
9
|
# use InjectionGuardAgent
|
|
10
|
-
# stop_if ->(result) { result.
|
|
10
|
+
# stop_if ->(result) { result.processed["detected"] == true }
|
|
11
11
|
# end
|
|
12
12
|
#
|
|
13
13
|
# step :translate, TranslationAgent # shorthand — no stop_if
|
|
@@ -44,7 +44,7 @@ module ActiveHarness
|
|
|
44
44
|
# Full block form:
|
|
45
45
|
# step :injection_guard do
|
|
46
46
|
# use InjectionGuardAgent
|
|
47
|
-
# stop_if ->(result) { result.
|
|
47
|
+
# stop_if ->(result) { result.processed["detected"] == true }
|
|
48
48
|
# end
|
|
49
49
|
def step(name, agent_class = nil, &block)
|
|
50
50
|
pipeline_config[:steps] << Pipeline::Step.new(name, agent_class, &block)
|
|
@@ -210,23 +210,13 @@ module ActiveHarness
|
|
|
210
210
|
end
|
|
211
211
|
|
|
212
212
|
def execute_step(step)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
).call
|
|
221
|
-
else
|
|
222
|
-
agent_streams = { token: @token_stream, agent: @agent_event_stream }.compact
|
|
223
|
-
step.agent_class.new(
|
|
224
|
-
input: @payload,
|
|
225
|
-
context: @context.dup,
|
|
226
|
-
params: @params,
|
|
227
|
-
streams: agent_streams
|
|
228
|
-
).call.result
|
|
229
|
-
end
|
|
213
|
+
streams = { token: @token_stream, agent: @agent_event_stream, tribunal: @tribunal_event_stream }.compact
|
|
214
|
+
step.agent_class.new(
|
|
215
|
+
input: @payload,
|
|
216
|
+
context: @context.dup,
|
|
217
|
+
params: @params,
|
|
218
|
+
streams: streams
|
|
219
|
+
).call.result
|
|
230
220
|
end
|
|
231
221
|
end
|
|
232
222
|
end
|
|
@@ -4,8 +4,20 @@ module ActiveHarness
|
|
|
4
4
|
# Minimal result wrapper returned by Agent#call.
|
|
5
5
|
#
|
|
6
6
|
# output — raw string from the provider
|
|
7
|
-
#
|
|
7
|
+
# processed — for format :json: a Ruby Hash/Array; for format :text: same as output
|
|
8
8
|
# usage — token counts: { input_tokens:, output_tokens:, total_tokens: } or nil for streaming
|
|
9
9
|
# cost — { input_cost:, output_cost:, total_cost: } in USD, or nil if pricing unavailable
|
|
10
|
-
Result = Struct.new(
|
|
10
|
+
Result = Struct.new(
|
|
11
|
+
:input,
|
|
12
|
+
:output,
|
|
13
|
+
:processed,
|
|
14
|
+
:system_prompt,
|
|
15
|
+
:provider, :model,
|
|
16
|
+
:temperature,
|
|
17
|
+
:model_list,
|
|
18
|
+
:attempts,
|
|
19
|
+
:execution_time,
|
|
20
|
+
:usage,
|
|
21
|
+
:cost,
|
|
22
|
+
keyword_init: true)
|
|
11
23
|
end
|
|
@@ -13,7 +13,7 @@ module ActiveHarness
|
|
|
13
13
|
# Receives the full results array; return value becomes #verdict.
|
|
14
14
|
# Takes priority over +verdict+ strategy if both are declared.
|
|
15
15
|
#
|
|
16
|
-
# process { |results| results.all? { |r| r.
|
|
16
|
+
# process { |results| results.all? { |r| r.processed["result"] == true } }
|
|
17
17
|
def process(&block)
|
|
18
18
|
tribunal_config[:process] = block
|
|
19
19
|
end
|
|
@@ -31,11 +31,11 @@ module ActiveHarness
|
|
|
31
31
|
# The block receives a single Result and must return a truthy/falsy value.
|
|
32
32
|
#
|
|
33
33
|
# verdict :unanimous do |result|
|
|
34
|
-
# result.
|
|
34
|
+
# result.processed["result"] == true
|
|
35
35
|
# end
|
|
36
36
|
#
|
|
37
37
|
# verdict :majority, may_fail: 1 do |result|
|
|
38
|
-
# result.
|
|
38
|
+
# result.processed["result"] == true
|
|
39
39
|
# end
|
|
40
40
|
VALID_STRATEGIES = %i[unanimous majority].freeze
|
|
41
41
|
|
|
@@ -2,7 +2,7 @@ module ActiveHarness
|
|
|
2
2
|
class Tribunal
|
|
3
3
|
# Instance-level process block — overrides class-level block.
|
|
4
4
|
#
|
|
5
|
-
# tribunal.process { |results| results.count { |r| r.
|
|
5
|
+
# tribunal.process { |results| results.count { |r| r.processed["ok"] } >= 2 }
|
|
6
6
|
def process(&block)
|
|
7
7
|
@process_block = block
|
|
8
8
|
self
|
|
@@ -17,14 +17,14 @@ module ActiveHarness
|
|
|
17
17
|
# timeout: 7
|
|
18
18
|
# )
|
|
19
19
|
# tribunal.on(:after_agent) { |result| puts result.model }
|
|
20
|
-
# tribunal.process { |results| results.all? { |r| r.
|
|
20
|
+
# tribunal.process { |results| results.all? { |r| r.processed["result"] == true } }
|
|
21
21
|
# tribunal.call
|
|
22
22
|
#
|
|
23
23
|
# Subclass with DSL:
|
|
24
24
|
# class ContentQualityTribunal < ActiveHarness::Tribunal
|
|
25
25
|
# agents PolitenessAgent, ConstructivenessAgent
|
|
26
26
|
# on(:after_agent) { |result| puts result.model }
|
|
27
|
-
# process { |results| results.all? { |r| r.
|
|
27
|
+
# process { |results| results.all? { |r| r.processed["result"] == true } }
|
|
28
28
|
# end
|
|
29
29
|
# ContentQualityTribunal.new(input: "...").call
|
|
30
30
|
#
|
|
@@ -89,6 +89,17 @@ module ActiveHarness
|
|
|
89
89
|
@agent_execution_times = []
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
# Returns a Result with processed: { "verdict" => @verdict } so the pipeline
|
|
93
|
+
# can handle agents and tribunals through the same interface.
|
|
94
|
+
def result
|
|
95
|
+
Result.new(
|
|
96
|
+
input: @input,
|
|
97
|
+
output: nil,
|
|
98
|
+
processed: { "verdict" => @verdict },
|
|
99
|
+
execution_time: @execution_time
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
92
103
|
# Run all agents in parallel, then compute the verdict.
|
|
93
104
|
# Returns self so calls can be chained: tribunal.call.verdict
|
|
94
105
|
#
|
data/lib/active_harness.rb
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
class AppMemory < ActiveHarness::Memory
|
|
2
|
-
# Usage: AppMemory.new(
|
|
1
|
+
class AppMemory < ActiveHarness::Memory::JsonFile
|
|
2
|
+
# Usage: AppMemory.new(file_name: "users/42")
|
|
3
3
|
#
|
|
4
|
-
# Wraps ActiveHarness::Memory with project defaults so callers
|
|
5
|
-
# only need to pass a
|
|
6
|
-
def initialize(
|
|
4
|
+
# Wraps ActiveHarness::Memory::JsonFile with project defaults so callers
|
|
5
|
+
# only need to pass a file_name. Slashes create subdirectories.
|
|
6
|
+
def initialize(file_name:, **opts)
|
|
7
7
|
super(
|
|
8
|
-
|
|
8
|
+
file_name: file_name,
|
|
9
|
+
storage_path: Rails.root.join("storage", "ai", "memory").to_s,
|
|
9
10
|
depth: 10,
|
|
10
|
-
adapter: :file,
|
|
11
|
-
path: Rails.root.join("storage", "ai", "memory").to_s,
|
|
12
11
|
storage_size: 200,
|
|
13
|
-
pretty: Rails.env.development
|
|
12
|
+
pretty: Rails.env.development?,
|
|
13
|
+
**opts
|
|
14
14
|
)
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
class <%= class_name %>Memory < ActiveHarness::Memory
|
|
2
|
-
def initialize(
|
|
1
|
+
class <%= class_name %>Memory < ActiveHarness::Memory::JsonFile
|
|
2
|
+
def initialize(file_name:, **opts)
|
|
3
3
|
super(
|
|
4
|
-
|
|
4
|
+
file_name: file_name,
|
|
5
|
+
storage_path: Rails.root.join("storage", "ai", "memory").to_s,
|
|
5
6
|
depth: 10,
|
|
6
|
-
adapter: :file,
|
|
7
|
-
path: Rails.root.join("storage", "ai", "memory").to_s,
|
|
8
7
|
storage_size: 200,
|
|
9
|
-
pretty: Rails.env.development
|
|
8
|
+
pretty: Rails.env.development?,
|
|
9
|
+
**opts
|
|
10
10
|
)
|
|
11
11
|
end
|
|
12
12
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module ActiveHarness
|
|
5
|
+
module Generators
|
|
6
|
+
class MemoryPostgresqlGenerator < Rails::Generators::Base
|
|
7
|
+
include Rails::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates the ActiveHarness PostgreSQL memory migration"
|
|
12
|
+
|
|
13
|
+
def self.next_migration_number(dirname)
|
|
14
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_migration_file
|
|
18
|
+
migration_template "create_active_harness_memory_turns.rb.tt",
|
|
19
|
+
"db/migrate/create_active_harness_memory_turns.rb"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateActiveHarnessMemoryTurns < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :active_harness_memory_turns do |t|
|
|
4
|
+
t.text :session_id, null: false
|
|
5
|
+
t.text :namespace
|
|
6
|
+
t.text :request, null: false
|
|
7
|
+
t.text :response, null: false
|
|
8
|
+
t.jsonb :meta, null: false, default: {}
|
|
9
|
+
t.datetime :created_at, null: false, default: -> { "NOW()" }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :active_harness_memory_turns,
|
|
13
|
+
[:session_id, :namespace, :id],
|
|
14
|
+
name: "idx_ah_memory_turns_session"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module ActiveHarness
|
|
5
|
+
module Generators
|
|
6
|
+
class MemorySqliteGenerator < Rails::Generators::Base
|
|
7
|
+
include Rails::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates the ActiveHarness SQLite memory migration"
|
|
12
|
+
|
|
13
|
+
def self.next_migration_number(dirname)
|
|
14
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_migration_file
|
|
18
|
+
migration_template "create_active_harness_memory_turns.rb.tt",
|
|
19
|
+
"db/migrate/create_active_harness_memory_turns.rb"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/generators/active_harness/memory_sqlite/templates/create_active_harness_memory_turns.rb.tt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateActiveHarnessMemoryTurns < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :active_harness_memory_turns do |t|
|
|
4
|
+
t.text :session_id, null: false
|
|
5
|
+
t.text :namespace
|
|
6
|
+
t.text :request, null: false
|
|
7
|
+
t.text :response, null: false
|
|
8
|
+
t.text :meta, null: false, default: "{}"
|
|
9
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :active_harness_memory_turns,
|
|
13
|
+
[:session_id, :namespace, :id],
|
|
14
|
+
name: "idx_ah_memory_turns_session"
|
|
15
|
+
end
|
|
16
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_harness
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.26
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- the-teacher
|
|
@@ -50,9 +50,11 @@ files:
|
|
|
50
50
|
- lib/active_harness/http/streaming_client.rb
|
|
51
51
|
- lib/active_harness/memory.rb
|
|
52
52
|
- lib/active_harness/memory/adapter/base.rb
|
|
53
|
-
- lib/active_harness/memory/adapter/
|
|
54
|
-
- lib/active_harness/memory/
|
|
53
|
+
- lib/active_harness/memory/adapter/json_file.rb
|
|
54
|
+
- lib/active_harness/memory/adapter/postgresql.rb
|
|
55
|
+
- lib/active_harness/memory/adapter/sqlite.rb
|
|
55
56
|
- lib/active_harness/pipeline.rb
|
|
57
|
+
- lib/active_harness/pipeline/README.md
|
|
56
58
|
- lib/active_harness/pipeline/hooks.rb
|
|
57
59
|
- lib/active_harness/pipeline/step.rb
|
|
58
60
|
- lib/active_harness/providers/PROVIDER_CONTRACT.md
|
|
@@ -92,6 +94,10 @@ files:
|
|
|
92
94
|
- lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb
|
|
93
95
|
- lib/generators/active_harness/memory/memory_generator.rb
|
|
94
96
|
- lib/generators/active_harness/memory/templates/memory.rb.tt
|
|
97
|
+
- lib/generators/active_harness/memory_postgresql/memory_postgresql_generator.rb
|
|
98
|
+
- lib/generators/active_harness/memory_postgresql/templates/create_active_harness_memory_turns.rb.tt
|
|
99
|
+
- lib/generators/active_harness/memory_sqlite/memory_sqlite_generator.rb
|
|
100
|
+
- lib/generators/active_harness/memory_sqlite/templates/create_active_harness_memory_turns.rb.tt
|
|
95
101
|
- lib/generators/active_harness/pipeline/pipeline_generator.rb
|
|
96
102
|
- lib/generators/active_harness/pipeline/templates/pipeline.rb.tt
|
|
97
103
|
- lib/generators/active_harness/prompt/prompt_generator.rb
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
module ActiveHarness
|
|
2
|
-
class Memory
|
|
3
|
-
# File-backed memory with a path-safe file_name interface.
|
|
4
|
-
#
|
|
5
|
-
# Differences from the base Memory class:
|
|
6
|
-
# - file_name: replaces session_id — may contain slashes to create
|
|
7
|
-
# subdirectories under storage_path, e.g. "users/42/chat"
|
|
8
|
-
# Final file is always <storage_path>/<file_name>.json
|
|
9
|
-
# - storage_path: replaces the adapter-level path: option
|
|
10
|
-
# - adapter: always :file — no other adapter can be passed
|
|
11
|
-
#
|
|
12
|
-
# Path traversal is rejected: segments equal to "." or ".." or containing
|
|
13
|
-
# null bytes raise ArgumentError before any file I/O happens.
|
|
14
|
-
# Missing directories are created automatically on the first write.
|
|
15
|
-
class JsonFile < Memory
|
|
16
|
-
DEFAULT_STORAGE_PATH = "storage/ai/memory"
|
|
17
|
-
|
|
18
|
-
def initialize(file_name:, storage_path: DEFAULT_STORAGE_PATH, **opts)
|
|
19
|
-
super(
|
|
20
|
-
session_id: sanitize!(file_name),
|
|
21
|
-
adapter: :file,
|
|
22
|
-
path: storage_path,
|
|
23
|
-
**opts
|
|
24
|
-
)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
private
|
|
28
|
-
|
|
29
|
-
def sanitize!(raw)
|
|
30
|
-
parts = raw.to_s.split("/").map(&:strip).reject(&:empty?)
|
|
31
|
-
raise ArgumentError, "file_name must not be empty" if parts.empty?
|
|
32
|
-
|
|
33
|
-
parts.each do |part|
|
|
34
|
-
if part == ".." || part == "." || part.include?("\0")
|
|
35
|
-
raise ArgumentError, "Invalid file_name segment: #{part.inspect}"
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
parts.last.sub!(/\.json\z/i, "")
|
|
40
|
-
parts.join("/")
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|