activeagent 1.8.0 → 1.9.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1193 -0
  3. data/README.md +9 -1
  4. data/lib/active_agent/base.rb +22 -1
  5. data/lib/active_agent/concerns/delegation.rb +4 -0
  6. data/lib/active_agent/concerns/input_requests.rb +178 -0
  7. data/lib/active_agent/concerns/parameterized.rb +7 -4
  8. data/lib/active_agent/concerns/provider.rb +1 -0
  9. data/lib/active_agent/concerns/tooling.rb +7 -2
  10. data/lib/active_agent/delegation/runner.rb +31 -0
  11. data/lib/active_agent/evals/catalog.rb +305 -0
  12. data/lib/active_agent/evals/diagnosis.rb +3 -3
  13. data/lib/active_agent/evals/format.rb +115 -0
  14. data/lib/active_agent/evals/judge.rb +2 -0
  15. data/lib/active_agent/evals/report.rb +232 -31
  16. data/lib/active_agent/evals/report_html.rb +316 -149
  17. data/lib/active_agent/evals/result.rb +40 -0
  18. data/lib/active_agent/evals/ruby_llm.rb +411 -0
  19. data/lib/active_agent/evals/runner.rb +6 -2
  20. data/lib/active_agent/evals/scenario.rb +10 -3
  21. data/lib/active_agent/evals/scenario_parser.rb +38 -10
  22. data/lib/active_agent/evals.rb +2 -0
  23. data/lib/active_agent/generation.rb +62 -2
  24. data/lib/active_agent/generation_job.rb +14 -3
  25. data/lib/active_agent/input_request/resume.rb +199 -0
  26. data/lib/active_agent/input_request.rb +279 -0
  27. data/lib/active_agent/providers/_base_provider.rb +153 -31
  28. data/lib/active_agent/providers/anthropic/transforms.rb +57 -28
  29. data/lib/active_agent/providers/anthropic_provider.rb +102 -20
  30. data/lib/active_agent/providers/common/responses/prompt.rb +21 -0
  31. data/lib/active_agent/providers/concerns/input_requests.rb +332 -0
  32. data/lib/active_agent/providers/concerns/mcp_serving.rb +267 -0
  33. data/lib/active_agent/providers/concerns/tool_choice_clearing.rb +18 -3
  34. data/lib/active_agent/providers/deep_seek_provider.rb +6 -0
  35. data/lib/active_agent/providers/deepseek/_types.rb +4 -0
  36. data/lib/active_agent/providers/deepseek/chat/_types.rb +54 -0
  37. data/lib/active_agent/providers/deepseek/chat/request.rb +121 -0
  38. data/lib/active_agent/providers/deepseek/options.rb +53 -0
  39. data/lib/active_agent/providers/deepseek_provider.rb +46 -0
  40. data/lib/active_agent/providers/log_subscriber.rb +13 -0
  41. data/lib/active_agent/providers/mcp_bridge.rb +691 -0
  42. data/lib/active_agent/providers/mcp_tool_cache.rb +230 -0
  43. data/lib/active_agent/providers/open_ai/_base.rb +1 -1
  44. data/lib/active_agent/providers/open_ai/chat_provider.rb +22 -3
  45. data/lib/active_agent/providers/open_ai/options.rb +1 -1
  46. data/lib/active_agent/providers/open_ai/responses_provider.rb +68 -15
  47. data/lib/active_agent/providers/open_router/options.rb +6 -2
  48. data/lib/active_agent/providers/ruby_llm/messages/_types.rb +3 -1
  49. data/lib/active_agent/providers/ruby_llm/options.rb +5 -0
  50. data/lib/active_agent/providers/ruby_llm_provider.rb +127 -29
  51. data/lib/active_agent/schema_tools.rb +12 -0
  52. data/lib/active_agent/telemetry/instrumentation.rb +10 -1
  53. data/lib/active_agent/version.rb +1 -1
  54. data/lib/active_agent.rb +2 -0
  55. data/lib/generators/active_agent/schema_tools/USAGE +11 -0
  56. data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +39 -4
  57. data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +13 -0
  58. metadata +32 -2
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module ActiveAgent
6
+ module Evals
7
+ # A catalog of evaluation scenarios: the products an app evaluates, each
8
+ # with the named sets of scenarios it is tested with. One YAML document,
9
+ # or several layered like Suite's: a later document's product, set or
10
+ # scenario with a key already present replaces it, a new key is appended.
11
+ #
12
+ # catalog: support_desk
13
+ # name: Support Desk
14
+ # description: What the support agents are expected to handle
15
+ # products:
16
+ # - key: triage
17
+ # name: Triage agent
18
+ # agent: TriageAgent # the agent class, or a dashboard agent's name
19
+ # sets:
20
+ # - key: smoke
21
+ # name: Smoke
22
+ # judge: { kind: rules } # optional; an evaluation's judge_kind and judge_model
23
+ # criteria: [] # optional; an evaluation's criteria
24
+ # scenarios:
25
+ # - key: refund_request
26
+ # prompt: A customer asks for a refund on order 1042.
27
+ # expect:
28
+ # tools: [lookup_order]
29
+ # contains: [refund]
30
+ # notes: Looks the order up before answering.
31
+ # tags: [billing]
32
+ # params: { locale: en }
33
+ # production_only: false
34
+ #
35
+ # A suite document (`suite:`, `groups:`) loads as a catalog of one
36
+ # product, named after the suite, whose sets are the suite's groups, so
37
+ # every existing `config/evals/*.yml` is already a catalog.
38
+ #
39
+ # The catalog is the file format and nothing more: `scenarios` hands the
40
+ # Runner its scenarios, `suite_for` gives one product's sets to code that
41
+ # takes a Suite, and `to_yaml` writes the canonical document that
42
+ # `digest` fingerprints, so two catalogs with the same content read the
43
+ # same wherever they were written.
44
+ class Catalog
45
+ class NotFound < StandardError; end
46
+ class InvalidDocument < StandardError; end
47
+
48
+ # The keys a product, set or scenario may carry. Anything else is kept
49
+ # under `metadata` so a document round-trips.
50
+ PRODUCT_KEYS = %w[key name description agent sets].freeze
51
+ SET_KEYS = %w[key name description judge criteria scenarios].freeze
52
+ SCENARIO_KEYS = %w[key prompt expect expectations tools contains not_contains notes tags params production_only position].freeze
53
+ CATALOG_KEYS = %w[catalog name description products suite groups].freeze
54
+
55
+ Product = Struct.new(:key, :name, :description, :agent, :metadata, :sets, :position, keyword_init: true) do
56
+ def set(key)
57
+ sets.find { |set| set.key == key.to_s }
58
+ end
59
+
60
+ def scenarios
61
+ sets.flat_map(&:scenarios)
62
+ end
63
+ end
64
+
65
+ Set = Struct.new(:key, :name, :description, :judge, :criteria, :metadata, :scenarios, :position, keyword_init: true)
66
+
67
+ attr_reader :key, :name, :description, :metadata, :products
68
+
69
+ # Loads the documents at `paths` (missing files are skipped), base
70
+ # first; raises NotFound when none exist.
71
+ def self.load(*paths, name: nil)
72
+ existing = paths.flatten.map(&:to_s).select { |path| File.exist?(path) }
73
+ raise NotFound, "no scenario catalog at #{paths.flatten.join(', ')}" if existing.empty?
74
+
75
+ documents = existing.map { |path| YAML.safe_load_file(path, aliases: true) || {} }
76
+ new(documents, name: name || File.basename(existing.first, ".yml"))
77
+ end
78
+
79
+ # Parses YAML text. Raises InvalidDocument for YAML that is not a
80
+ # document (a list, a scalar) or that YAML cannot read.
81
+ def self.parse(text, name: nil)
82
+ document = YAML.safe_load(text.to_s, aliases: true, permitted_classes: [ Symbol ])
83
+ raise InvalidDocument, "a scenario catalog is a YAML mapping" unless document.is_a?(Hash)
84
+
85
+ new([ document ], name: name)
86
+ rescue Psych::SyntaxError => e
87
+ raise InvalidDocument, "the catalog is not valid YAML: #{e.message}"
88
+ end
89
+
90
+ # @param documents [Hash, Array<Hash>] parsed YAML documents, base first
91
+ def initialize(documents, name: nil)
92
+ documents = Array.wrap(documents).map { |document| normalize(document.to_h.deep_stringify_keys, name) }
93
+ @key = documents.filter_map { |doc| doc["catalog"].presence }.last.to_s.presence || name.to_s.presence
94
+ raise InvalidDocument, "the catalog has no key: set `catalog:` in the document" if @key.blank?
95
+
96
+ @name = documents.filter_map { |doc| doc["name"].presence }.last || @key
97
+ @description = documents.filter_map { |doc| doc["description"].presence }.last
98
+ @metadata = documents.map { |doc| doc.except(*CATALOG_KEYS) }.reduce({}, :merge)
99
+ @products = build_products(merge_products(documents))
100
+ end
101
+
102
+ # The product with +key+, or NotFound.
103
+ def product(key)
104
+ products.find { |product| product.key == key.to_s } ||
105
+ raise(NotFound, "no product #{key.inspect} in catalog #{self.key}")
106
+ end
107
+
108
+ # The set +set_key+ of product +product_key+, or NotFound.
109
+ def set(product_key, set_key)
110
+ product(product_key).set(set_key) ||
111
+ raise(NotFound, "no set #{set_key.inspect} in product #{product_key.inspect} of catalog #{key}")
112
+ end
113
+
114
+ # Scenarios across the catalog, narrowed by product key, set key,
115
+ # scenario keys, or any of them; `production_only` scenarios are
116
+ # dropped unless `include_production_only` is true. Each scenario's
117
+ # group is its set's key and its group_name the set's name.
118
+ def scenarios(product: nil, set: nil, keys: nil, include_production_only: true)
119
+ selected = products
120
+ selected = selected.select { |entry| entry.key == product.to_s } if product.present?
121
+ selected = selected.flat_map(&:sets)
122
+ selected = selected.select { |entry| entry.key == set.to_s } if set.present?
123
+ selected = selected.flat_map(&:scenarios)
124
+ selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) } if keys.present?
125
+ selected = selected.reject(&:production_only?) unless include_production_only
126
+ selected
127
+ end
128
+
129
+ def all_scenarios
130
+ scenarios
131
+ end
132
+
133
+ def scenario_count
134
+ products.sum { |product| product.sets.sum { |set| set.scenarios.size } }
135
+ end
136
+
137
+ # One product's sets as a Suite (every set a group), or one set alone,
138
+ # for code that takes a suite: `Runner.new(scenarios: catalog.suite_for(:triage).scenarios, ...)`.
139
+ def suite_for(product_key, set_key = nil)
140
+ found = product(product_key)
141
+ sets = set_key ? [ set(product_key, set_key) ] : found.sets
142
+ Suite.new([ {
143
+ "suite" => [ key, found.key, set_key ].compact.join("/"),
144
+ "description" => found.description || description,
145
+ "groups" => sets.map { |entry| set_document(entry) }
146
+ } ])
147
+ end
148
+
149
+ # The canonical document: keys in a fixed order, nothing empty, so the
150
+ # same content always reads the same.
151
+ def to_h
152
+ compact({
153
+ "catalog" => key,
154
+ "name" => name,
155
+ "description" => description,
156
+ "products" => products.map { |product| product_document(product) }
157
+ }.merge(metadata))
158
+ end
159
+
160
+ def to_yaml
161
+ YAML.dump(to_h)
162
+ end
163
+
164
+ # SHA-256 of the canonical document.
165
+ def digest
166
+ Digest::SHA256.hexdigest(to_yaml)
167
+ end
168
+
169
+ private
170
+
171
+ # A suite document becomes a catalog document of one product.
172
+ def normalize(document, name)
173
+ return document if document.key?("products") || document.key?("catalog")
174
+ return document unless document.key?("suite") || document.key?("groups")
175
+
176
+ product_key = document["suite"].presence || name.to_s.presence || "default"
177
+ {
178
+ "catalog" => document["catalog"].presence || product_key,
179
+ "name" => document["name"],
180
+ "description" => document["description"],
181
+ "products" => [ {
182
+ "key" => product_key,
183
+ "name" => document["name"].presence || product_key,
184
+ "description" => document["description"],
185
+ "sets" => Array(document["groups"])
186
+ } ]
187
+ }.compact
188
+ end
189
+
190
+ def merge_products(documents)
191
+ documents.each_with_object([]) do |document, products|
192
+ Array(document["products"]).each do |incoming|
193
+ incoming = incoming.to_h.deep_stringify_keys
194
+ raise InvalidDocument, "a product has no key" if incoming["key"].blank?
195
+
196
+ existing = products.find { |product| product["key"] == incoming["key"].to_s }
197
+ if existing
198
+ %w[name description agent].each { |field| existing[field] = incoming[field] if incoming[field].present? }
199
+ existing.merge!(incoming.except(*PRODUCT_KEYS))
200
+ merge_sets(existing, Array(incoming["sets"]))
201
+ else
202
+ products << incoming.merge("key" => incoming["key"].to_s, "sets" => [])
203
+ .tap { |product| merge_sets(product, Array(incoming["sets"])) }
204
+ end
205
+ end
206
+ end
207
+ end
208
+
209
+ def merge_sets(product, incoming_sets)
210
+ incoming_sets.each do |incoming|
211
+ incoming = incoming.to_h.deep_stringify_keys
212
+ raise InvalidDocument, "a set of product #{product['key'].inspect} has no key" if incoming["key"].blank?
213
+
214
+ existing = product["sets"].find { |set| set["key"] == incoming["key"].to_s }
215
+ if existing
216
+ %w[name description judge criteria].each { |field| existing[field] = incoming[field] if incoming[field].present? }
217
+ existing.merge!(incoming.except(*SET_KEYS))
218
+ merge_scenarios(existing, Array(incoming["scenarios"]))
219
+ else
220
+ product["sets"] << incoming.merge("key" => incoming["key"].to_s, "scenarios" => [])
221
+ .tap { |set| merge_scenarios(set, Array(incoming["scenarios"])) }
222
+ end
223
+ end
224
+ end
225
+
226
+ def merge_scenarios(set, incoming_scenarios)
227
+ incoming_scenarios.each do |scenario|
228
+ scenario = scenario.to_h.deep_stringify_keys
229
+ raise InvalidDocument, "a scenario in set #{set['key'].inspect} has no key" if scenario["key"].blank?
230
+ raise InvalidDocument, "scenario #{scenario['key'].inspect} has no prompt" if scenario["prompt"].blank?
231
+
232
+ scenario["key"] = scenario["key"].to_s
233
+ index = set["scenarios"].index { |existing| existing["key"] == scenario["key"] }
234
+ index ? set["scenarios"][index] = scenario : set["scenarios"] << scenario
235
+ end
236
+ end
237
+
238
+ def build_products(documents)
239
+ documents.each_with_index.map do |product, position|
240
+ Product.new(
241
+ key: product["key"],
242
+ name: product["name"].presence || product["key"],
243
+ description: product["description"].presence,
244
+ agent: product["agent"].presence&.to_s,
245
+ metadata: product.except(*PRODUCT_KEYS),
246
+ position: position,
247
+ sets: product["sets"].each_with_index.map { |set, set_position| build_set(set, set_position) }
248
+ )
249
+ end
250
+ end
251
+
252
+ def build_set(set, position)
253
+ Set.new(
254
+ key: set["key"],
255
+ name: set["name"].presence || set["key"],
256
+ description: set["description"].presence,
257
+ judge: (set["judge"] || {}).to_h.stringify_keys,
258
+ criteria: Array(set["criteria"]).map { |criterion| criterion.to_h.deep_stringify_keys },
259
+ metadata: set.except(*SET_KEYS),
260
+ position: position,
261
+ scenarios: set["scenarios"].each_with_index.map do |entry, index|
262
+ Scenario.from_hash(entry.merge("position" => index), group: set["key"], group_name: set["name"].presence || set["key"])
263
+ end
264
+ )
265
+ end
266
+
267
+ def product_document(product)
268
+ compact({
269
+ "key" => product.key,
270
+ "name" => product.name,
271
+ "description" => product.description,
272
+ "agent" => product.agent,
273
+ "sets" => product.sets.map { |set| set_document(set) }
274
+ }.merge(product.metadata))
275
+ end
276
+
277
+ def set_document(set)
278
+ compact({
279
+ "key" => set.key,
280
+ "name" => set.name,
281
+ "description" => set.description,
282
+ "judge" => set.judge,
283
+ "criteria" => set.criteria,
284
+ "scenarios" => set.scenarios.map { |scenario| scenario_document(scenario) }
285
+ }.merge(set.metadata))
286
+ end
287
+
288
+ def scenario_document(scenario)
289
+ compact({
290
+ "key" => scenario.key,
291
+ "prompt" => scenario.prompt,
292
+ "expect" => scenario.expectations,
293
+ "notes" => scenario.notes,
294
+ "tags" => scenario.tags,
295
+ "params" => scenario.params,
296
+ "production_only" => (true if scenario.production_only?)
297
+ })
298
+ end
299
+
300
+ def compact(hash)
301
+ hash.reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty? && value != false) }
302
+ end
303
+ end
304
+ end
305
+ end
@@ -285,11 +285,11 @@ module ActiveAgent
285
285
 
286
286
  weakest = (failed_grade ? grades : @scores.compact).min_by { |_, value| value }
287
287
  summary = if failed_grade
288
- "#{graded_label(grades)} scored #{grade.round(2)} against a pass threshold of #{@threshold}"
288
+ "#{graded_label(grades)} scored #{Format.score(grade)} against a pass threshold of #{Format.percent(@threshold)}"
289
289
  else
290
- "Scored #{@score.round(2)} against a pass threshold of #{@threshold}"
290
+ "Scored #{Format.score(@score)} against a pass threshold of #{Format.percent(@threshold)}"
291
291
  end
292
- summary += ", weakest on #{weakest.first} (#{weakest.last.round(2)})" if weakest
292
+ summary += ", weakest on #{weakest.first} (#{Format.score(weakest.last)})" if weakest
293
293
  recommendation =
294
294
  if weakest
295
295
  "Read the answer against the #{weakest.first.to_s.humanize.downcase} criterion and adjust the " \
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # How every evaluation surface writes a pass count, a score and a cost, so
6
+ # the HTML report, the Markdown report, a diagnosis and the dashboard read
7
+ # alike:
8
+ #
9
+ # - a pass/fail fraction carries its percentage: "14/16 · 88%" (in
10
+ # Markdown "14/16 (88%)"); nothing scored (0/0) reads "—"
11
+ # - a 0..1 score — a mean score, a criterion score, task completion, a
12
+ # judge's confidence, the pass threshold — reads as a whole percent:
13
+ # "93%", "pass ≥ 70%"
14
+ # - money has four decimals, six below $0.001, "$0.00" for an explicit
15
+ # zero, and a leading "~" when any part of it was estimated from
16
+ # tokens × model rates rather than reported; LEGEND explains the mark
17
+ #
18
+ # Percentages round half up to a whole number. Stored values stay 0..1
19
+ # and USD; only their display changes here. The dashboard's
20
+ # actionagent/frontend/utils/evalFormat.mjs is the JavaScript copy of
21
+ # these rules, so a change here needs the same change there.
22
+ module Format
23
+ EMPTY = "—"
24
+ # Shown once per surface where a "~" figure is visible.
25
+ LEGEND = "~ estimated from tokens × model rates"
26
+ # Rate sources that are a guess at the model's price rather than a
27
+ # catalog entry for it (see the dashboard's ModelPricing).
28
+ FALLBACK_RATE_SOURCES = %w[pattern default].freeze
29
+
30
+ module_function
31
+
32
+ # 0.875 → "88%". "—" for a missing value.
33
+ def percent(fraction)
34
+ return EMPTY unless finite?(fraction)
35
+
36
+ "#{whole(fraction.to_f * 100)}%"
37
+ end
38
+
39
+ # 14 of 16 → "14/16 · 88%", or "14/16 (88%)" with `style: :markdown`,
40
+ # where " · " would read as a table cell separator next to a pipe.
41
+ # "—" when nothing was scored.
42
+ def passes(passed, total, style: :text)
43
+ count = total.to_i
44
+ return EMPTY unless count.positive?
45
+
46
+ done = passed.to_i
47
+ pct = "#{whole(done * 100.0 / count)}%"
48
+ style == :markdown ? "#{done}/#{count} (#{pct})" : "#{done}/#{count} · #{pct}"
49
+ end
50
+
51
+ # A 0..1 score as a percent: 0.93 → "93%".
52
+ def score(value)
53
+ percent(value)
54
+ end
55
+
56
+ # 0.7 → "pass ≥ 70%".
57
+ def threshold(value)
58
+ "pass ≥ #{percent(value)}"
59
+ end
60
+
61
+ # 0.0243 → "$0.0243", 0.000697 → "$0.000697", 0 → "$0.00"; with
62
+ # `estimated`, "~$0.0243". "—" for a missing value.
63
+ def money(value, estimated: false)
64
+ return EMPTY unless finite?(value)
65
+
66
+ amount = value.to_f
67
+ digits = if amount.zero? then 2
68
+ elsif amount.abs < 0.001 then 6
69
+ else 4
70
+ end
71
+ "#{'~' if estimated}$#{format("%.#{digits}f", amount)}"
72
+ end
73
+
74
+ # A $/M token rate with at least two decimals and no trailing noise:
75
+ # 5 → "$5.00/M", 0.075 → "$0.075/M".
76
+ def per_million(rate)
77
+ whole, fraction = format("%.4f", rate.to_f).split(".")
78
+ fraction = fraction.sub(/0+\z/, "")
79
+ "$#{whole}.#{fraction.ljust(2, '0')}/M"
80
+ end
81
+
82
+ # The tooltip of an estimated figure: how it was worked out, e.g.
83
+ # "estimated: 2,328 in × $5.00/M + 423 out × $30.00/M · catalog rate".
84
+ # `rate` is `{ "input", "output", "source" }` in $ per million tokens;
85
+ # a rate from the name-pattern table or the default appends
86
+ # "(fallback rate)". Without a rate it names the method only.
87
+ def cost_title(input_tokens: nil, output_tokens: nil, rate: nil)
88
+ rate = rate.to_h.transform_keys(&:to_s) if rate.respond_to?(:to_h)
89
+ return "estimated from tokens × model rates" unless rate.is_a?(Hash) && finite?(rate["input"]) && finite?(rate["output"])
90
+
91
+ source = rate["source"].presence || "catalog"
92
+ title = "estimated: #{delimited(input_tokens)} in × #{per_million(rate['input'])} + " \
93
+ "#{delimited(output_tokens)} out × #{per_million(rate['output'])} · #{source} rate"
94
+ title += " (fallback rate)" if FALLBACK_RATE_SOURCES.include?(source.to_s)
95
+ title
96
+ end
97
+
98
+ # Half up, on the decimal value: 14.5 → 15, even when the float arrived
99
+ # as 14.499999999999998.
100
+ def whole(value)
101
+ value.to_f.round(9).round
102
+ end
103
+
104
+ def delimited(count)
105
+ count.to_i.to_s.gsub(/(\d)(?=(\d{3})+\z)/, '\1,')
106
+ end
107
+
108
+ def finite?(value)
109
+ return false if value.nil? || value == ""
110
+
111
+ Float(value, exception: false)&.finite? || false
112
+ end
113
+ end
114
+ end
115
+ end
@@ -158,6 +158,8 @@ module ActiveAgent
158
158
  def verdict(summaries, instructions: nil)
159
159
  lines = summaries.map do |label, stats|
160
160
  faults = (stats["faults"] || {}).map { |fault, count| "#{fault}×#{count}" }.join(", ")
161
+ # The agent's cost only: what the judge itself spent scoring a
162
+ # cohort says nothing about the model under comparison.
161
163
  "#{label}: pass rate #{stats['pass_rate']}%, mean score #{stats['avg_score'] || 'n/a'}, " \
162
164
  "avg latency #{stats['avg_duration_ms'] || 'n/a'}ms, cost $#{stats['cost'] || 'n/a'}" \
163
165
  "#{", faults: #{faults}" if faults.present?}"