agent_eval_planner 0.1.0 → 0.2.0
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/.gitignore +6 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +7 -0
- data/Gemfile +10 -0
- data/README.md +75 -9
- data/Rakefile +8 -0
- data/agent_eval_planner.gemspec +42 -0
- data/docs/.nojekyll +0 -0
- data/docs/examples/sample-contract.md +15 -0
- data/docs/examples/sample-plan.md +238 -0
- data/docs/examples/sample-remediations.md +245 -0
- data/docs/examples/sample-scope-01-native.json +37 -0
- data/docs/examples/sample-scope-01.json +32 -0
- data/docs/examples/sample-suite.jsonl +13 -0
- data/docs/favicon.svg +5 -0
- data/docs/index.html +469 -0
- data/docs/script.js +71 -0
- data/docs/styles.css +546 -0
- data/lib/agent_eval_planner/cli.rb +37 -8
- data/lib/agent_eval_planner/plan_renderer.rb +26 -5
- data/lib/agent_eval_planner/remediation_renderer.rb +12 -1
- data/lib/agent_eval_planner/suite_renderer.rb +49 -15
- data/lib/agent_eval_planner/suite_validator.rb +297 -62
- data/lib/agent_eval_planner/version.rb +1 -1
- data/lib/agent_eval_planner.rb +7 -2
- metadata +18 -1
|
@@ -19,27 +19,61 @@ module AgentEvalPlanner
|
|
|
19
19
|
attr_reader :analyzer, :vectors
|
|
20
20
|
|
|
21
21
|
def row_for(vector)
|
|
22
|
-
|
|
22
|
+
return native_row(vector) if analyzer.harness == "native"
|
|
23
|
+
|
|
24
|
+
legacy_row(vector)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def legacy_row(vector)
|
|
23
28
|
{
|
|
24
29
|
"id" => stable_id(vector),
|
|
25
30
|
"input" => {
|
|
26
31
|
"target_agent" => analyzer.agent_name,
|
|
27
32
|
"messages" => [{ "role" => "user", "content" => vector.attack_prompt }]
|
|
28
33
|
},
|
|
29
|
-
"expected" =>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
"expected" => expectations(vector),
|
|
35
|
+
"metadata" => metadata(vector, "plan_id")
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def native_row(vector)
|
|
40
|
+
{
|
|
41
|
+
"id" => stable_id(vector),
|
|
42
|
+
"target_component" => analyzer.agent_name,
|
|
43
|
+
"suite" => "regression",
|
|
44
|
+
"turns" => [
|
|
45
|
+
{
|
|
46
|
+
"sequence" => 0,
|
|
47
|
+
"input" => {
|
|
48
|
+
"messages" => [{ "role" => "user", "content" => vector.attack_prompt }]
|
|
49
|
+
},
|
|
50
|
+
"expectations" => expectations(vector).merge(
|
|
51
|
+
"source_link" => nil,
|
|
52
|
+
"ticket_handoff" => false
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"metadata" => metadata(vector, "vector_id")
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def expectations(vector)
|
|
61
|
+
{
|
|
62
|
+
"reference_answer" => vector.expected_validation,
|
|
63
|
+
"expected_tools" => [],
|
|
64
|
+
"forbidden_tools" => forbidden_tools_for(vector),
|
|
65
|
+
"refusal" => vector.refusal
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def metadata(vector, link_field)
|
|
70
|
+
{
|
|
71
|
+
"intent" => vector.intent,
|
|
72
|
+
"split" => vector.split,
|
|
73
|
+
"priority" => vector.severity == "P0" ? "high" : "medium",
|
|
74
|
+
"tags" => vector.tags,
|
|
75
|
+
"requires_live_data" => false,
|
|
76
|
+
link_field => vector.id
|
|
43
77
|
}
|
|
44
78
|
end
|
|
45
79
|
|
|
@@ -4,9 +4,10 @@ require "json"
|
|
|
4
4
|
require "set"
|
|
5
5
|
|
|
6
6
|
module AgentEvalPlanner
|
|
7
|
-
# Hard-fail validator for
|
|
7
|
+
# Hard-fail validator for legacy rows and native rows.
|
|
8
8
|
class SuiteValidator
|
|
9
9
|
PLACEHOLDER_RE = /\{\{[^{}]+\}\}/
|
|
10
|
+
ID_RE = /\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/
|
|
10
11
|
GUARDRAIL_INTENTS = %w[
|
|
11
12
|
out_of_scope_refusal
|
|
12
13
|
prompt_injection
|
|
@@ -15,13 +16,23 @@ module AgentEvalPlanner
|
|
|
15
16
|
pii_probe
|
|
16
17
|
].freeze
|
|
17
18
|
GUARDRAIL_TAGS = %w[scope injection role exfil pii guardrail].freeze
|
|
19
|
+
DEFAULT_SUITES = %w[golden regression routing calibration production-sample].freeze
|
|
20
|
+
SCHEMAS = %w[auto legacy native].freeze
|
|
18
21
|
|
|
19
|
-
def initialize(path:, known_tools: [], agent_has_no_tools: false
|
|
22
|
+
def initialize(path:, known_tools: [], bound_tools: nil, agent_has_no_tools: false,
|
|
23
|
+
target_components: nil, suites: nil, schema: "auto")
|
|
20
24
|
@path = path
|
|
21
|
-
@known_tools =
|
|
25
|
+
@known_tools = clean_names(known_tools)
|
|
26
|
+
@bound_tools = clean_names(bound_tools)
|
|
22
27
|
@agent_has_no_tools = agent_has_no_tools
|
|
28
|
+
@target_components = clean_names(target_components)
|
|
29
|
+
@suites = clean_names(suites)
|
|
30
|
+
@schema = schema
|
|
31
|
+
@warnings = []
|
|
23
32
|
end
|
|
24
33
|
|
|
34
|
+
attr_reader :warnings
|
|
35
|
+
|
|
25
36
|
def validate!
|
|
26
37
|
errors = validate
|
|
27
38
|
raise ValidationError, errors.join("\n") if errors.any?
|
|
@@ -33,9 +44,18 @@ module AgentEvalPlanner
|
|
|
33
44
|
raise InputError, "Arquivo não encontrado: #{path}" unless File.file?(path)
|
|
34
45
|
|
|
35
46
|
raw = File.read(path, encoding: "UTF-8")
|
|
47
|
+
@warnings = []
|
|
36
48
|
errors = placeholder_errors(raw) + mode_errors
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
run = {
|
|
50
|
+
errors: errors,
|
|
51
|
+
warnings: [],
|
|
52
|
+
seen_ids: {},
|
|
53
|
+
seen_schemas: Set.new,
|
|
54
|
+
inventory: inventory,
|
|
55
|
+
allowed_components: target_components.to_set,
|
|
56
|
+
allowed_suites: (suites.any? ? suites : DEFAULT_SUITES).to_set,
|
|
57
|
+
schema: SCHEMAS.include?(schema) ? schema : "auto"
|
|
58
|
+
}
|
|
39
59
|
|
|
40
60
|
raw.each_line.with_index(1) do |line, line_no|
|
|
41
61
|
stripped = line.strip
|
|
@@ -43,43 +63,67 @@ module AgentEvalPlanner
|
|
|
43
63
|
|
|
44
64
|
row, load_errors = load_row(stripped, line_no)
|
|
45
65
|
if row.nil?
|
|
46
|
-
errors.concat(load_errors)
|
|
66
|
+
run[:errors].concat(load_errors)
|
|
47
67
|
next
|
|
48
68
|
end
|
|
49
69
|
|
|
50
|
-
|
|
51
|
-
errors.concat(id_errors)
|
|
52
|
-
next if row_id.nil?
|
|
53
|
-
|
|
54
|
-
errors.concat(errors_for_row(row, row_id, known: known))
|
|
70
|
+
collect_row(run, row, line_no)
|
|
55
71
|
end
|
|
56
72
|
|
|
57
|
-
errors
|
|
73
|
+
run[:errors].concat(mixed_schema_error(run[:seen_schemas]))
|
|
74
|
+
@warnings = run[:warnings]
|
|
75
|
+
run[:errors]
|
|
58
76
|
end
|
|
59
77
|
|
|
60
78
|
private
|
|
61
79
|
|
|
62
|
-
attr_reader :path, :known_tools, :agent_has_no_tools
|
|
80
|
+
attr_reader :path, :known_tools, :bound_tools, :agent_has_no_tools,
|
|
81
|
+
:target_components, :suites, :schema
|
|
82
|
+
|
|
83
|
+
def clean_names(names)
|
|
84
|
+
Array(names).map(&:to_s).reject(&:empty?)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def guardrail_metadata?(metadata)
|
|
88
|
+
return true if GUARDRAIL_INTENTS.include?(metadata["intent"].to_s)
|
|
89
|
+
|
|
90
|
+
tags = Array(metadata["tags"]).map(&:to_s)
|
|
91
|
+
(tags & GUARDRAIL_TAGS).any?
|
|
92
|
+
end
|
|
63
93
|
|
|
64
94
|
def placeholder_errors(raw)
|
|
65
|
-
matches = raw.scan(PLACEHOLDER_RE).uniq
|
|
95
|
+
matches = raw.scan(PLACEHOLDER_RE).uniq.sort
|
|
66
96
|
return [] if matches.empty?
|
|
67
97
|
|
|
68
|
-
["placeholders remaining (must be replaced before delivery): #{matches
|
|
98
|
+
["placeholders remaining (must be replaced before delivery): #{matches}"]
|
|
69
99
|
end
|
|
70
100
|
|
|
71
101
|
def mode_errors
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
102
|
+
findings = []
|
|
103
|
+
if agent_has_no_tools && (known_tools.any? || bound_tools.any?)
|
|
104
|
+
findings << "pass either --agent-has-no-tools OR tool flags, not both"
|
|
105
|
+
elsif !agent_has_no_tools && known_tools.empty? && bound_tools.empty?
|
|
106
|
+
findings << "agent tool inventory required: pass --bound-tools a,b " \
|
|
107
|
+
"(and --known-tools when the product catalog is wider) " \
|
|
108
|
+
"or --agent-has-no-tools if the agent truly has no tools"
|
|
109
|
+
elsif bound_tools.any? && known_tools.any? && !(bound_tools.to_set <= known_tools.to_set)
|
|
110
|
+
extra = (bound_tools.to_set - known_tools.to_set).to_a.sort
|
|
111
|
+
findings << "--bound-tools must be a subset of --known-tools; extra: #{extra}"
|
|
80
112
|
end
|
|
113
|
+
findings << "--schema must be auto, legacy, or native; got #{schema.inspect}" unless SCHEMAS.include?(schema)
|
|
114
|
+
findings
|
|
115
|
+
end
|
|
81
116
|
|
|
82
|
-
|
|
117
|
+
def inventory
|
|
118
|
+
known = (known_tools.any? ? known_tools : bound_tools).to_set
|
|
119
|
+
bound = (bound_tools.any? ? bound_tools : known_tools).to_set
|
|
120
|
+
separated = bound_tools.any? && known_tools.any? && bound_tools.to_set < known_tools.to_set
|
|
121
|
+
{
|
|
122
|
+
known: known,
|
|
123
|
+
bound: bound,
|
|
124
|
+
bound_explicit: separated,
|
|
125
|
+
agent_has_no_tools: agent_has_no_tools
|
|
126
|
+
}
|
|
83
127
|
end
|
|
84
128
|
|
|
85
129
|
def load_row(line, line_no)
|
|
@@ -91,78 +135,269 @@ module AgentEvalPlanner
|
|
|
91
135
|
[nil, ["line #{line_no}: invalid JSON (#{e.message})"]]
|
|
92
136
|
end
|
|
93
137
|
|
|
138
|
+
def row_schema(row)
|
|
139
|
+
native = row.key?("turns") || row.key?("target_component") || row.key?("suite")
|
|
140
|
+
legacy = row.key?("input") || row.key?("expected")
|
|
141
|
+
return "mixed" if native && legacy
|
|
142
|
+
return "native" if native
|
|
143
|
+
|
|
144
|
+
"legacy"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def collect_row(run, row, line_no)
|
|
148
|
+
kind = row_schema(row)
|
|
149
|
+
return if reject_schema(run, line_no, kind)
|
|
150
|
+
|
|
151
|
+
row_id, id_errors = row_id_errors(row, line_no, run[:seen_ids])
|
|
152
|
+
run[:errors].concat(id_errors)
|
|
153
|
+
return if row_id.nil?
|
|
154
|
+
|
|
155
|
+
append_shape_errors(run, row, row_id, kind)
|
|
156
|
+
append_expectation_findings(run, row, row_id, kind)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def reject_schema(run, line_no, kind)
|
|
160
|
+
if kind == "mixed"
|
|
161
|
+
run[:errors] << "line #{line_no}: row mixes legacy input/expected with native turns"
|
|
162
|
+
return true
|
|
163
|
+
end
|
|
164
|
+
if run[:schema] != "auto" && kind != run[:schema]
|
|
165
|
+
run[:errors] << "line #{line_no}: expected --schema #{run[:schema]}, found #{kind}"
|
|
166
|
+
return true
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
run[:seen_schemas] << kind
|
|
170
|
+
false
|
|
171
|
+
end
|
|
172
|
+
|
|
94
173
|
def row_id_errors(row, line_no, seen_ids)
|
|
95
174
|
row_id = row["id"]
|
|
96
175
|
return [nil, ["line #{line_no}: missing id"]] if row_id.nil? || row_id.to_s.empty?
|
|
97
176
|
|
|
98
177
|
row_id_str = row_id.to_s
|
|
99
178
|
errors = []
|
|
179
|
+
unless ID_RE.match?(row_id_str)
|
|
180
|
+
errors << "line #{line_no}: id #{row_id.inspect} must match " \
|
|
181
|
+
"[A-Za-z0-9][A-Za-z0-9._:-]* (stable id)"
|
|
182
|
+
end
|
|
100
183
|
errors << "line #{line_no}: duplicate id #{row_id.inspect}" if seen_ids[row_id_str]
|
|
101
184
|
seen_ids[row_id_str] = true
|
|
102
185
|
[row_id_str, errors]
|
|
103
186
|
end
|
|
104
187
|
|
|
105
|
-
def
|
|
106
|
-
|
|
107
|
-
|
|
188
|
+
def append_shape_errors(run, row, row_id, kind)
|
|
189
|
+
if kind == "native"
|
|
190
|
+
run[:errors].concat(native_shape_errors(row, row_id, run))
|
|
191
|
+
else
|
|
192
|
+
run[:errors].concat(legacy_component_errors(row, row_id, run[:allowed_components]))
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def native_shape_errors(row, row_id, run)
|
|
197
|
+
errors = []
|
|
198
|
+
component = row["target_component"]
|
|
199
|
+
if !component.is_a?(String) || component.empty?
|
|
200
|
+
errors << "#{row_id}: native row requires target_component"
|
|
201
|
+
elsif run[:allowed_components].any? && !run[:allowed_components].include?(component)
|
|
202
|
+
errors << "#{row_id}: target_component #{component.inspect} not in " \
|
|
203
|
+
"--target-components #{run[:allowed_components].to_a.sort}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
suite_name = row["suite"]
|
|
207
|
+
if !suite_name.is_a?(String) || suite_name.empty?
|
|
208
|
+
errors << "#{row_id}: native row requires suite"
|
|
209
|
+
elsif !run[:allowed_suites].include?(suite_name)
|
|
210
|
+
errors << "#{row_id}: suite #{suite_name.inspect} not in #{run[:allowed_suites].to_a.sort}"
|
|
211
|
+
end
|
|
212
|
+
errors
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def legacy_component_errors(row, row_id, allowed)
|
|
216
|
+
return [] if allowed.empty?
|
|
217
|
+
|
|
218
|
+
target_input = row["input"]
|
|
219
|
+
return ["#{row_id}: missing input.target_agent"] unless target_input.is_a?(Hash)
|
|
220
|
+
|
|
221
|
+
component = target_input["target_agent"]
|
|
222
|
+
return [] if allowed.include?(component)
|
|
223
|
+
|
|
224
|
+
["#{row_id}: target_agent #{component.inspect} not in --target-components #{allowed.to_a.sort}"]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def append_expectation_findings(run, row, row_id, kind)
|
|
228
|
+
blocks, block_errors = expectation_blocks(row, row_id, kind)
|
|
229
|
+
run[:errors].concat(block_errors)
|
|
230
|
+
blocks.each do |label, expectations, own_guardrail|
|
|
231
|
+
found, warned = errors_for_expectations("#{row_id} #{label}", expectations, own_guardrail, run[:inventory])
|
|
232
|
+
run[:errors].concat(found)
|
|
233
|
+
run[:warnings].concat(warned)
|
|
234
|
+
end
|
|
235
|
+
run[:errors].concat(row_guardrail_gap(row, row_id, kind, blocks, run[:inventory]))
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def expectation_blocks(row, row_id, kind)
|
|
239
|
+
return legacy_block(row, row_id) if kind == "legacy"
|
|
108
240
|
|
|
109
|
-
|
|
241
|
+
turns = row["turns"]
|
|
242
|
+
return [[], ["#{row_id}: native row requires a non-empty turns list"]] unless turns.is_a?(Array) && turns.any?
|
|
110
243
|
|
|
111
|
-
|
|
112
|
-
empty_guardrail_forbidden_errors(row, row_id, forbidden) +
|
|
113
|
-
unknown_tools_errors(row_id, forbidden, known) +
|
|
114
|
-
placeholder_tools_errors(row_id, forbidden)
|
|
244
|
+
native_blocks(row_id, turns)
|
|
115
245
|
end
|
|
116
246
|
|
|
117
|
-
def
|
|
118
|
-
expected = row["expected"]
|
|
119
|
-
|
|
120
|
-
return [nil, ["#{row_id}: missing expected.forbidden_tools"]] if forbidden.nil?
|
|
121
|
-
return [nil, ["#{row_id}: forbidden_tools must be a list"]] unless forbidden.is_a?(Array)
|
|
247
|
+
def legacy_block(row, row_id)
|
|
248
|
+
expected = row["expected"]
|
|
249
|
+
return [[], ["#{row_id}: missing expected object"]] unless expected.is_a?(Hash)
|
|
122
250
|
|
|
123
|
-
[
|
|
251
|
+
metadata = row["metadata"].is_a?(Hash) ? row["metadata"] : {}
|
|
252
|
+
own_guardrail = guardrail_metadata?(metadata) || expected["refusal"] == true
|
|
253
|
+
[[["expected", expected, own_guardrail]], []]
|
|
124
254
|
end
|
|
125
255
|
|
|
126
|
-
def
|
|
127
|
-
|
|
256
|
+
def native_blocks(row_id, turns)
|
|
257
|
+
blocks = []
|
|
258
|
+
errors = []
|
|
259
|
+
sequences = []
|
|
260
|
+
turns.each_with_index do |turn, index|
|
|
261
|
+
block, sequence, turn_errors = one_turn(row_id, index, turn)
|
|
262
|
+
errors.concat(turn_errors)
|
|
263
|
+
sequences << sequence unless sequence.nil?
|
|
264
|
+
blocks << block unless block.nil?
|
|
265
|
+
end
|
|
266
|
+
if sequences.any? && sequences != (0...sequences.length).to_a
|
|
267
|
+
errors << "#{row_id}: turn sequence must be contiguous from 0; got #{sequences}"
|
|
268
|
+
end
|
|
269
|
+
[blocks, errors]
|
|
270
|
+
end
|
|
128
271
|
|
|
129
|
-
|
|
272
|
+
def one_turn(row_id, index, turn)
|
|
273
|
+
label = "turns[#{index}].expectations"
|
|
274
|
+
return [nil, nil, ["#{row_id}: #{label} turn must be an object"]] unless turn.is_a?(Hash)
|
|
275
|
+
|
|
276
|
+
errors = []
|
|
277
|
+
sequence = turn["sequence"]
|
|
278
|
+
recorded = sequence.is_a?(Integer) ? sequence : nil
|
|
279
|
+
errors << "#{row_id}: turns[#{index}].sequence must be an int" if recorded.nil?
|
|
280
|
+
expectations = turn["expectations"]
|
|
281
|
+
unless expectations.is_a?(Hash)
|
|
282
|
+
errors << "#{row_id}: missing #{label}"
|
|
283
|
+
return [nil, recorded, errors]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
turn_meta = turn["metadata"].is_a?(Hash) ? turn["metadata"] : {}
|
|
287
|
+
own_guardrail = guardrail_metadata?(turn_meta) || expectations["refusal"] == true
|
|
288
|
+
[[label, expectations, own_guardrail], recorded, errors]
|
|
130
289
|
end
|
|
131
290
|
|
|
132
|
-
def
|
|
133
|
-
|
|
134
|
-
|
|
291
|
+
def errors_for_expectations(where, expectations, guardrail, inventory)
|
|
292
|
+
forbidden, errors = name_list(expectations, where, "forbidden_tools")
|
|
293
|
+
expected, expected_errors = name_list(expectations, where, "expected_tools")
|
|
294
|
+
errors.concat(expected_errors)
|
|
295
|
+
return [errors, []] if forbidden.nil? || expected.nil?
|
|
135
296
|
|
|
136
|
-
|
|
297
|
+
errors.concat(placeholder_names(where, "forbidden_tools", forbidden))
|
|
298
|
+
errors.concat(placeholder_names(where, "expected_tools", expected))
|
|
299
|
+
if inventory[:agent_has_no_tools]
|
|
300
|
+
return [errors + findings_when_empty(where, forbidden, expected), []]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
errors.concat(unknown_name_findings(where, forbidden, expected, inventory[:known]))
|
|
304
|
+
bound_errors, warned = bound_findings(where, expectations, forbidden, expected, inventory, guardrail)
|
|
305
|
+
[errors + bound_errors, warned]
|
|
137
306
|
end
|
|
138
307
|
|
|
139
|
-
def
|
|
140
|
-
|
|
141
|
-
|
|
308
|
+
def name_list(expectations, row_id, field)
|
|
309
|
+
names = expectations[field]
|
|
310
|
+
return [[], []] if names.nil? && field != "forbidden_tools"
|
|
311
|
+
return [nil, ["#{row_id}: missing #{field}"]] if names.nil?
|
|
312
|
+
return [nil, ["#{row_id}: #{field} must be a list"]] unless names.is_a?(Array)
|
|
313
|
+
|
|
314
|
+
[names, []]
|
|
315
|
+
end
|
|
142
316
|
|
|
143
|
-
|
|
317
|
+
def placeholder_names(row_id, field, names)
|
|
318
|
+
names.filter_map do |name|
|
|
319
|
+
next unless PLACEHOLDER_RE.match?(name.to_s)
|
|
320
|
+
|
|
321
|
+
"#{row_id}: placeholder left in #{field}: #{name.inspect}"
|
|
144
322
|
end
|
|
145
323
|
end
|
|
146
324
|
|
|
147
|
-
def
|
|
148
|
-
return []
|
|
149
|
-
return [] unless forbidden.empty?
|
|
325
|
+
def findings_when_empty(where, forbidden, expected)
|
|
326
|
+
return [] if forbidden.empty? && expected.empty?
|
|
150
327
|
|
|
151
328
|
[
|
|
152
|
-
"#{
|
|
153
|
-
"
|
|
154
|
-
"detect tool abuse (false sense of safety)"
|
|
329
|
+
"#{where}: agent-has-no-tools but tools were listed " \
|
|
330
|
+
"(forbidden=#{forbidden.inspect}, expected=#{expected.inspect})"
|
|
155
331
|
]
|
|
156
332
|
end
|
|
157
333
|
|
|
158
|
-
def
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
334
|
+
def unknown_name_findings(where, forbidden, expected, known)
|
|
335
|
+
findings = []
|
|
336
|
+
unknown_forbidden = forbidden.reject { |name| known.include?(name) }
|
|
337
|
+
unknown_expected = expected.reject { |name| known.include?(name) }
|
|
338
|
+
if unknown_forbidden.any?
|
|
339
|
+
findings << "#{where}: forbidden_tools not in --known-tools: #{unknown_forbidden}"
|
|
340
|
+
end
|
|
341
|
+
if unknown_expected.any?
|
|
342
|
+
findings << "#{where}: expected_tools not in --known-tools: #{unknown_expected}"
|
|
343
|
+
end
|
|
344
|
+
findings
|
|
345
|
+
end
|
|
163
346
|
|
|
164
|
-
|
|
165
|
-
|
|
347
|
+
def bound_findings(where, expectations, forbidden, expected, inventory, guardrail)
|
|
348
|
+
errors = []
|
|
349
|
+
outside_bound = expected.reject { |name| inventory[:bound].include?(name) }
|
|
350
|
+
if outside_bound.any?
|
|
351
|
+
errors << "#{where}: expected_tools not bound to this target: #{outside_bound}"
|
|
352
|
+
end
|
|
353
|
+
if expectations["refusal"] == true && expected.any?
|
|
354
|
+
errors << "#{where}: refusal true cannot require expected_tools=#{expected.inspect}"
|
|
355
|
+
end
|
|
356
|
+
if guardrail && forbidden.none? { |name| inventory[:bound].include?(name) }
|
|
357
|
+
errors << "#{where}: HARD FAIL - guardrail/refusal forbids no tool this " \
|
|
358
|
+
"target can call; suite cannot detect tool abuse"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
warnings = []
|
|
362
|
+
if inventory[:bound_explicit]
|
|
363
|
+
unbound = forbidden.select { |name| inventory[:known].include?(name) && !inventory[:bound].include?(name) }
|
|
364
|
+
if unbound.any?
|
|
365
|
+
warnings << "#{where}: WARN - forbidden_tools names tools this target " \
|
|
366
|
+
"cannot call (assertion observes nothing): #{unbound}"
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
[errors, warnings]
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def forbids_bound?(expectations, bound)
|
|
373
|
+
listed = expectations["forbidden_tools"]
|
|
374
|
+
return false unless listed.is_a?(Array)
|
|
375
|
+
|
|
376
|
+
listed.any? { |name| bound.include?(name) }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def row_guardrail_gap(row, row_id, kind, blocks, inventory)
|
|
380
|
+
return [] unless kind == "native"
|
|
381
|
+
return [] if inventory[:agent_has_no_tools]
|
|
382
|
+
|
|
383
|
+
metadata = row["metadata"].is_a?(Hash) ? row["metadata"] : {}
|
|
384
|
+
return [] unless guardrail_metadata?(metadata)
|
|
385
|
+
return [] if blocks.any? { |(_, expectations, _)| forbids_bound?(expectations, inventory[:bound]) }
|
|
386
|
+
|
|
387
|
+
[
|
|
388
|
+
"#{row_id}: HARD FAIL - guardrail metadata requires at least one turn " \
|
|
389
|
+
"to forbid a tool this target can call"
|
|
390
|
+
]
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def mixed_schema_error(seen_schemas)
|
|
394
|
+
return [] if seen_schemas.size < 2
|
|
395
|
+
|
|
396
|
+
names = seen_schemas.to_a.sort.join(", ")
|
|
397
|
+
[
|
|
398
|
+
"mixed suite schemas in one file: #{names}. " \
|
|
399
|
+
"Split legacy rows from native rows."
|
|
400
|
+
]
|
|
166
401
|
end
|
|
167
402
|
end
|
|
168
403
|
end
|
data/lib/agent_eval_planner.rb
CHANGED
|
@@ -18,11 +18,16 @@ module AgentEvalPlanner
|
|
|
18
18
|
Planner.new(input_path: input_path, raw: raw, **options).call
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def validate_suite(path, known_tools: [], agent_has_no_tools: false
|
|
21
|
+
def validate_suite(path, known_tools: [], bound_tools: nil, agent_has_no_tools: false,
|
|
22
|
+
target_components: nil, suites: nil, schema: "auto")
|
|
22
23
|
SuiteValidator.new(
|
|
23
24
|
path: path,
|
|
24
25
|
known_tools: known_tools,
|
|
25
|
-
|
|
26
|
+
bound_tools: bound_tools,
|
|
27
|
+
agent_has_no_tools: agent_has_no_tools,
|
|
28
|
+
target_components: target_components,
|
|
29
|
+
suites: suites,
|
|
30
|
+
schema: schema
|
|
26
31
|
).validate
|
|
27
32
|
end
|
|
28
33
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: agent_eval_planner
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Saulo Filho
|
|
@@ -49,9 +49,26 @@ executables:
|
|
|
49
49
|
extensions: []
|
|
50
50
|
extra_rdoc_files: []
|
|
51
51
|
files:
|
|
52
|
+
- ".gitignore"
|
|
53
|
+
- ".rspec"
|
|
54
|
+
- ".ruby-version"
|
|
52
55
|
- CHANGELOG.md
|
|
56
|
+
- Gemfile
|
|
53
57
|
- LICENSE.txt
|
|
54
58
|
- README.md
|
|
59
|
+
- Rakefile
|
|
60
|
+
- agent_eval_planner.gemspec
|
|
61
|
+
- docs/.nojekyll
|
|
62
|
+
- docs/examples/sample-contract.md
|
|
63
|
+
- docs/examples/sample-plan.md
|
|
64
|
+
- docs/examples/sample-remediations.md
|
|
65
|
+
- docs/examples/sample-scope-01-native.json
|
|
66
|
+
- docs/examples/sample-scope-01.json
|
|
67
|
+
- docs/examples/sample-suite.jsonl
|
|
68
|
+
- docs/favicon.svg
|
|
69
|
+
- docs/index.html
|
|
70
|
+
- docs/script.js
|
|
71
|
+
- docs/styles.css
|
|
55
72
|
- exe/agent-eval-planner
|
|
56
73
|
- lib/agent_eval_planner.rb
|
|
57
74
|
- lib/agent_eval_planner/cli.rb
|