ruby_llm-agent-evaluations 0.2.2 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 27494e2aa94d8c50b5f680ec367b98a0cc4cb04a4d7454858a64c7dd5e7b76d5
4
- data.tar.gz: 664eb1507d3a622c193bd4a50d11be565c3d9694dd14748f1f8b5241caa7ad92
3
+ metadata.gz: 234cfcef798c2ce853af3d76742761998f034d50d0af7407e03a54cd2ee13d87
4
+ data.tar.gz: 294265acd962e070043475a9378532461ef286efe0c09c3e51bd3d35095479ad
5
5
  SHA512:
6
- metadata.gz: e1103c795eef6354e35a3e547cddba3334d4e88e379fb6cf260f0f396c93ee0db5c05ab834c263938c5b884036d7f017e7c4768780f9c7f66f1e451241a531ac
7
- data.tar.gz: d1ab9f91ce0b83ac6522379bd20053588c265e417c69ddc53be24d859c847f0f7fc120a670acdfda243c99789faee3461279c0835d1a00ceecc7836d91cde29d
6
+ metadata.gz: b263299d8e3ddfae288ffe8c273972f59c7760dd9e1f7aedc442e28a6e759b11471d5aaeb01be6b232e1d6a0d2a5b43bbd39f84b949c31298d82b2948cbcb467
7
+ data.tar.gz: 9db863b992be470f3584000147484a07072b290321aacb2632bf2cac6a76c3e4c56f7a44658c3e7a82b35af54610e6949ebd27ee7a4105b3c1236b052551026f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-10-12
4
+
5
+ - Evaluate structured response components independently from text, with protocol/version checks and application-defined validators. Preserve component evidence in history, exports, and the dashboard using existing tables.
6
+ - Add optional capture for actual tools without changing returns or callbacks; retain observed tool names and arguments. Plain response components do not fabricate tool provenance.
7
+ - Include a standalone A2UI 0.8 example with a plain Ruby renderer and no RubyLLM dependency. Correct text does not conceal incorrect or missing UI.
8
+ - Simplify component validation and capture, improve the README, and verify SQLite/PostgreSQL/MySQL regression flows. No new migrations or runtime dependencies.
9
+
3
10
  ## 0.2.2
4
11
 
5
12
  - Support PostgreSQL, SQLite, and MySQL 8.0+ through the ActiveRecord-selected adapter and one migration creating exactly three evaluation tables. Preserve UUID run IDs, append-only evidence, foreign keys, and chronological baseline ordering. MySQL uses binary, non-padding collation to preserve exact agent, case, and metric identities.
data/README.md CHANGED
@@ -1,370 +1,261 @@
1
1
  # RubyLLM Agent Evaluations
2
2
 
3
- Write questions and expected answers in Ruby, declare what matters, and keep evaluation evidence in PostgreSQL, SQLite, or MySQL through ActiveRecord. Compare compatible runs and inspect answers, metrics, errors, and action traces in a read-only dashboard.
3
+ [![Gem version](https://img.shields.io/gem/v/ruby_llm-agent-evaluations.svg)](https://rubygems.org/gems/ruby_llm-agent-evaluations)
4
+ [![Tests](https://github.com/luisgsilva950/ruby_llm-evaluations/actions/workflows/ci.yml/badge.svg)](https://github.com/luisgsilva950/ruby_llm-evaluations/actions/workflows/ci.yml)
4
5
 
5
- Works with any callable agent. RubyLLM is optional; Rails applications use named ActiveRecord models sharing their normal host connection pool.
6
+ **Evaluate agent behavior. Replay external APIs. Catch regressions before shipping a model, prompt, or tool change.**
6
7
 
7
- Install the package as `ruby_llm-agent-evaluations`:
8
+ - **Any callable agent.** Your application owns models, credentials, and orchestration; RubyLLM is optional.
9
+ - **Question-first scoring.** Semantic accuracy, relevancy, deterministic checks, and observed tool-call correctness.
10
+ - **Strict API replay.** Assert request parameters before returning inline or file-backed JSON.
11
+ - **Durable evidence.** SQLite, PostgreSQL, or MySQL 8.0+; compatible baselines, exportable reports, and a read-only dashboard.
8
12
 
9
- ~~~ruby
10
- gem "ruby_llm-agent-evaluations", "~> 0.2", require: "ruby_llm/evaluations"
11
- ~~~
13
+ [Quick start](#quick-start) · [Rails](#rails) · [Scoring](#scoring) · [API replay](#api-replay) · [Reports](#reports-and-dashboard) · [Examples](#examples)
12
14
 
13
- The executable is `ruby_llm-agent-evaluations`. The Ruby API remains `RubyLLM::Evaluations` with `require "ruby_llm/evaluations"`; Rails migration tasks and evaluation table names are unchanged, preserving existing history. The separately owned `ruby_llm-evaluations` package on RubyGems is not this project. The GitHub repository keeps its existing name.
15
+ ## Quick start
14
16
 
15
- ## Question-first specifications
17
+ Requires Ruby 3.2+, Bundler, and one database driver. **No Rails, PostgreSQL server, or API key required for this example.**
16
18
 
17
- Pass an optional RubyLLM judge agent class directly to the suite constructor. There is no global judge configuration or judge wrapper to build. Your application supplies the agent being evaluated and, when desired, its judge class:
19
+ Add to your `Gemfile`:
18
20
 
19
- ~~~ruby
20
- require "ruby_llm/evaluations"
21
-
22
- suite = RubyLLM::Evaluations.define("Customer support", agent: support_agent, judge: SupportJudgeAgent) do
23
- question "How long do I have to return an item?",
24
- expected_answer: "You can return it within 30 days.",
25
- minimum_scores: { semantic_accuracy: 0.9, answer_relevancy: 0.9 },
26
- metadata: { important_facts: ["The return deadline is 30 days."] }
27
- end
28
- ~~~
29
-
30
- | Spec field | Meaning |
31
- | --- | --- |
32
- | `question` | The actual request sent to your agent. |
33
- | `expected_answer` | A reference answer string, not a numeric score; paraphrases can pass semantic scoring. |
34
- | `minimum_scores` | A map of metric names to absolute, inclusive numeric passing thresholds from 0 to 1, applied on every run, including the first. |
35
- | `metadata` | Evaluation context, such as the essential facts that the judge must check. |
36
-
37
- `minimum_scores: { semantic_accuracy: 0.9 }` means that the calculated semantic-accuracy score must be at least 0.9, even on the first run when no baseline exists. It does not compare against a previous run, and 0.9 is not a promise of 90% string similarity with expected_answer: the semantic judge scores factual meaning against that reference.
38
-
39
- The library creates the selected metrics per question. Semantic accuracy measures factual meaning, accepting paraphrases while penalizing contradictions, unsupported additions, and omitted essential facts. Answer relevancy independently measures responsiveness: a wrong but on-topic answer can be relevant. Metadata is supplied as untrusted evaluation context; important_facts identifies factual priorities, not executable prompt instructions.
40
-
41
- Without explicit suite-level evaluate definitions, omitted minimum_scores defaults to semantic_accuracy and answer_relevancy at 0.9. These metrics run only when a judge is supplied. With no judge, LLM-as-judge metrics are not calculated; deterministic and action-group checks still run. No substitute or fabricated score is provided. Explicit minimum_scores chooses named standard/custom metrics per question. Thresholds are inclusive.
42
-
43
- Questions accept optional name:, id:, and expected_actions:. Names default to question text (or a generated label for structured questions); IDs default to names and must be unique. Question/reference/metadata/output data must be JSON-portable. Symbol keys/values retain Ruby distinctions in fingerprints and agent calls; persisted JSON normalizes them. Unsupported objects fail explicitly. Specs execute trusted Ruby, not sandboxed code, and must return a Suite; define returns one.
44
-
45
- ## Strict API replay
21
+ ```ruby
22
+ source "https://rubygems.org"
23
+ gem "ruby_llm-agent-evaluations", "~> 0.3.0", require: "ruby_llm/evaluations"
24
+ gem "sqlite3" # Or your application's pg/mysql2 driver.
25
+ ```
46
26
 
47
- Declare fixtures inside a question block to exercise real HTTP clients without contacting the API. This complete spec needs the ordinary database setup below, but no provider credentials:
27
+ Save as `support_eval.rb`. This offline lookup demonstrates the pipeline, not model reasoning:
48
28
 
49
- ~~~ruby
50
- require "net/http"
51
- require "json"
29
+ ```ruby
52
30
  require "ruby_llm/evaluations"
53
31
 
54
32
  E = RubyLLM::Evaluations
55
- shipping_agent = ->(_question) do
56
- response = Net::HTTP.get_response(URI("https://shipping.example/orders?id=42"))
57
- JSON.parse(response.body).fetch("status")
58
- end
33
+ policy = { "How long do I have to return an item?" => ENV.fetch("RETURN_WINDOW", "30 days") }
34
+ agent = ->(question) { policy.fetch(question) }
59
35
 
60
- E.define("Shipping API", agent: shipping_agent, agent_name: "ShippingAgent") do
61
- evaluate :exact, using: E::Evaluators.exact_match
62
- question "Track order 42", expected_answer: "ready" do
63
- api_interactions do
64
- expect_request(method: :get, url: "https://shipping.example/orders",
65
- query: { id: "42" })
66
- .respond_with(status: 200, headers: { "Content-Type" => "application/json" },
67
- json: { status: "ready" })
68
- end
69
- end
36
+ E.define("Return policy", agent: agent, agent_name: "SupportAgent", version: "1") do
37
+ evaluate :exact_match, using: E::Evaluators.exact_match
38
+ question "How long do I have to return an item?",
39
+ expected_answer: "30 days", minimum_scores: { exact_match: 1.0 }
70
40
  end
71
- ~~~
72
-
73
- Save it as `shipping_eval.rb`, then run `ruby_llm-agent-evaluations run shipping_eval.rb` after migrations. [examples/api_replay_eval.rb](examples/api_replay_eval.rb) also demonstrates file responses. Replay supports Net::HTTP and Faraday through standard WebMock-supported transports; it does not replace your agent or HTTP client. Only the agent call is wrapped, not evaluators or judges. Existing mock registrations, network configuration, and enabled adapters are restored after each case, including failures.
41
+ ```
74
42
 
75
- Run the bundled two-source example with `bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/api_replay_eval.rb`. Then run `AGENT_REVISION=regressed bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/api_replay_eval.rb --fail-on-regression` to send deliberately wrong order arguments; strict replay records the mismatch and the regression gate exits nonzero.
76
-
77
- Each declaration must be consumed **once, in declared order**. Declare a repeated call twice. Unexpected requests, parameter mismatches, exhausted fixtures, and missing calls produce an agent error even if the agent rescues the request exception or returns the expected answer. Query order is insignificant; repeated values and their multiplicity are preserved. `expect_request(json: { count: 1, enabled: false }, headers: { "X-Region" => "eu" })` asserts typed JSON and only declared headers (header names are case-insensitive). String `"1"` is not number `1`, and `null` is not `false`. Omitting request `json:` requires an empty body.
78
-
79
- `respond_with` requires exactly one source: inline `json:` (including `json: nil` for JSON null), or `body_file: "fixtures/order.json"`. Files resolve relative to the spec file where the question was declared, not the process working directory. Missing files, invalid JSON, and invalid UTF-8 fail before agent execution. Exact file bytes, including whitespace and final newline, are snapshotted and returned unchanged. Optional `status:` defaults to 200; `headers:` defaults to an empty map.
43
+ ```sh
44
+ bundle install
45
+ bundle exec ruby_llm-agent-evaluations db:migrate
46
+ bundle exec ruby_llm-agent-evaluations run support_eval.rb
47
+ RETURN_WINDOW='7 days' bundle exec ruby_llm-agent-evaluations run support_eval.rb --fail-on-regression
48
+ bundle exec ruby_llm-agent-evaluations history
49
+ bundle exec ruby_llm-agent-evaluations dashboard
50
+ ```
80
51
 
81
- Absolute `body_file:` paths are also accepted; prefer spec-relative paths for portable, shareable fixtures.
52
+ The reference passes; the changed answer exits **1**. Open **http://127.0.0.1:4567** to inspect both runs. Without `DATABASE_URL`, history persists in `ruby_llm_evaluations.sqlite3` in the current directory. Migrate once during setup, never per run.
82
53
 
83
- Replay is strict/offline by default: undeclared network requests fail. For live model requests made by the agent itself, explicitly use `api_interactions(allow_hosts: ["api.openai.com"]) do ... end`. Allowances match exact hosts, not suffixes or wildcards, and may incur provider charges. An allowed host never bypasses a declared fixture request. Judge calls need no replay allowance because judging runs outside replay. There is no recording mode.
54
+ ## Rails
84
55
 
85
- Case results persist `response_metadata["api_replay"]` on success and failure: expected interactions, observed match statuses, readable mismatch differences, and missing requests. Authentication/credential fields are redacted from replay evidence. Still use synthetic fixtures: never commit credentials or sensitive payloads, and protect persisted prompts, answers, and reports.
56
+ Keep your application's existing database driver, configuration, and connection pool:
86
57
 
87
- Replay evidence uses the existing response-metadata display in reports and the dashboard; JSON reports include the persisted `response_metadata.api_replay` payload. No separate evidence store or migration is required.
58
+ ```sh
59
+ bin/rails ruby_llm_evaluations:install:migrations
60
+ bin/rails db:migrate
61
+ ```
88
62
 
89
- Request assertions, response status/headers, allowed hosts, and response-byte hashes participate in suite fingerprints. Changing fixture bytes (even JSON whitespace) creates an incompatible baseline, not a regression against the old fixture. Existing definitions retain snapshotted bytes; reload/redefine the spec to adopt file changes. Suites without replay keep their existing fingerprints.
63
+ ```ruby
64
+ # config/routes.rb
65
+ mount RubyLLM::Evaluations::Engine => "/evaluations"
66
+ ```
90
67
 
91
- ## Large tool-result evaluation
68
+ The engine creates **three tables**: runs, case results, and metric results. It uses `jsonb` on PostgreSQL and `json` elsewhere; MySQL's `utf8mb4_0900_bin` collation preserves exact identifiers. Execution never reconfigures the host pool. Existing PostgreSQL history needs no reset; gem updates do not rewrite copied migrations.
92
69
 
93
- [examples/context_bloat_eval.rb](examples/context_bloat_eval.rb) replays a roughly 500 KB JSON API response with the relevant current policy after a long history. It evaluates three independent requirements: the correct answer, the expected tool name/arguments, and a maximum 4,096-byte model-facing tool result. The normal tool extracts the relevant facts; `AGENT_REVISION=regressed` sends the entire response. A correct answer does not excuse exceeding the declared tool-result budget. Actual API and tool-result byte counts are saved in response metadata.
70
+ Standalone library callers configure ActiveRecord themselves. The CLI selects its connection using `DATABASE_URL` or `--database-url`.
94
71
 
95
- This is a byte budget for tool output, not a tokenizer or a claim that the complete prompt fits every model window. The local integration test exercises actual RubyLLM HTTP/tool transport with scripted model responses; it does not prove model reasoning. Use the opt-in provider example to evaluate real model behavior. It requires your credentials and incurs charges; the external policy API remains replayed. A real provider context-limit failure is recorded as an agent error, never hidden.
72
+ **No built-in dashboard authentication.** Protect the Rails mount with application authorization.
96
73
 
97
- ~~~sh
98
- # Requires DATABASE_URL, migrations, and your OPENAI_API_KEY.
99
- RUN_CONTEXT_BLOAT_EVAL=1 OPENAI_MODEL=gpt-4.1-mini bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/context_bloat_eval.rb
74
+ ## Scoring
100
75
 
101
- # Intentionally expose oversized tool output; expect a failure or provider limit error.
102
- RUN_CONTEXT_BLOAT_EVAL=1 AGENT_REVISION=regressed OPENAI_MODEL=gpt-4.1-mini bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/context_bloat_eval.rb --fail-on-regression
103
- ~~~
76
+ `expected_answer` is the reference; `minimum_scores` contains inclusive thresholds in **[0, 1]**, applied even on the first run. Semantic scores are not string-similarity percentages.
104
77
 
105
- WebMock interception is process-global. Replay cases are serialized and restore caller state, but run evaluations in a dedicated process rather than alongside unrelated application HTTP traffic. Custom transports outside WebMock support are not intercepted.
78
+ | Dimension | Checks |
79
+ | --- | --- |
80
+ | `semantic_accuracy` | Factual meaning, contradictions, and omissions against the reference. |
81
+ | `answer_relevancy` | Responsiveness to the request; an on-topic answer can still be wrong. |
82
+ | `action_group` | Observed group, tool name, arguments, and order—including unexpected calls. |
106
83
 
107
- ## Optional judge agent in the suite constructor
84
+ Pass `judge: YourJudgeAgent`, a `RubyLLM::Agent` subclass, to `define` or `Suite.new` for semantic metrics. Each assessment uses a fresh instance; your application configures the model and credentials. See the complete [RubyLLM example](examples/ruby_llm_eval.rb). Provider calls incur charges.
108
85
 
109
- Use a class inheriting from RubyLLM::Agent (RubyLLM 1.14.1+). The evaluation suite creates a fresh instance for each assessment and calls its ask method, so judge conversations do not leak between metrics or questions:
86
+ Without a judge, LLM metrics are skipped. **No calculated metrics means `not_evaluated` with null quality/pass rate—not a passing substitute.** Unspecified metrics default to semantic accuracy/relevancy at 0.9 unless explicit evaluators are registered. Agent/evaluator failures remain errors; other checks continue.
110
87
 
111
- ~~~ruby
112
- require "ruby_llm"
113
- require "ruby_llm/evaluations"
88
+ Custom evaluators receive `(output, context)` and return a Boolean, finite score, or `Assessment`. Context includes case data and the full response:
114
89
 
115
- class SupportJudgeAgent < RubyLLM::Agent
116
- model ENV.fetch("EVAL_JUDGE_MODEL")
90
+ ```ruby
91
+ # Inside a suite; give the question metadata: { max_words: 12 }.
92
+ evaluate(:conciseness, threshold: 1.0) do |output, context|
93
+ output.split.size <= context.metadata.fetch(:max_words)
117
94
  end
95
+ ```
118
96
 
119
- suite = RubyLLM::Evaluations::Suite.new(
120
- "Customer support", agent: support_agent, judge: SupportJudgeAgent
121
- )
122
- suite.question "How long do I have to return an item?", expected_answer: "30 days",
123
- minimum_scores: { semantic_accuracy: 0.9, answer_relevancy: 0.9 }
124
- ~~~
125
-
126
- The define DSL forwards judge: to this same constructor. Provider credentials use your application's normal RubyLLM configuration; no separate evaluation-judge configuration is needed. Supplying a judge makes real model requests when the suite runs and can incur charges.
127
-
128
- Omit judge: to skip semantic_accuracy, answer_relevancy, and custom llm_judge checks. Declared minimum_scores and agent outputs are still persisted. A question with no calculated metrics is not_evaluated, not passed; score and pass rate are null when no questions were evaluated. Mixed suites calculate quality only from executed metrics and evaluated questions. A different calculated metric set changes the fingerprint, so runs that skipped LLM metrics are never automatic regression baselines for runs that calculated them.
97
+ Built-ins include `exact_match`, `contains`, `matches`, and `valid_json`. Action checks require observed `Response.actions` or the RubyLLM adapter's `action_groups`: `nil` means missing instrumentation; `[]` means no observed calls. Never infer execution from an answer.
129
98
 
130
- Judge replies must contain a finite numeric score in [0,1] and a nonempty string reason. Invalid replies and provider failures remain metric errors, never passing defaults. Model judgments are nondeterministic: calibrate thresholds against human-reviewed examples. Marking data untrusted does not eliminate prompt injection.
99
+ **Passing is not the same as avoiding regression.** A drop from 0.98 to 0.92 passes a 0.9 minimum but regresses beyond a 0.02 tolerance. Baselines match earlier suite/agent/definition fingerprints. Questions, references/actions, minimums, metadata, fixture bytes, and suite version affect compatibility. **Bump suite `version` when changing scoring code, rubrics, or judge configuration**; callable source is not hashed.
131
100
 
132
- [examples/ruby_llm_eval.rb](examples/ruby_llm_eval.rb) supplies a RubyLLM judge agent class to the suite. [examples/semantic_actions_eval.rb](examples/semantic_actions_eval.rb) combines real agent/tool/judge calls and all three dimensions. Configure provider credentials and EVAL_MODEL/EVAL_JUDGE_MODEL before running these opt-in examples; neither runs automatically.
101
+ ### Structured responses
133
102
 
134
- ## Rails: named models and standard migrations
103
+ Score UI/protocol payloads separately from text. **UI is response data, not a tool call.** Return your renderer's payload explicitly; no RubyLLM tool class or concern is needed:
135
104
 
136
- Add the gem to your application's Gemfile. During local development, add `path: "/path/to/this/checkout"` to the declaration:
137
-
138
- ~~~ruby
139
- gem "ruby_llm-agent-evaluations", "~> 0.2", require: "ruby_llm/evaluations"
140
- ~~~
141
-
142
- Persistence uses the adapter selected by ActiveRecord: PostgreSQL, SQLite, or MySQL 8.0+. The gem does not require `pg` or another database driver; add your chosen driver to the application's Gemfile (or keep the one already used by your Rails host):
143
-
144
- ~~~ruby
145
- gem "sqlite3" # SQLite
146
- # gem "pg" # PostgreSQL instead
147
- # gem "mysql2" # MySQL instead
148
- ~~~
149
-
150
- Rails uses its existing database configuration and normal connection pool; no separate evaluation connection or storage configuration is needed. MySQL tables use `utf8mb4_0900_bin` so case, accents, and trailing spaces remain distinct in identifiers and baseline matching.
151
-
152
- Install the engine migrations into the host application, then migrate normally:
153
-
154
- ~~~sh
155
- bin/rails ruby_llm_evaluations:install:migrations
156
- bin/rails db:migrate
157
- ~~~
158
-
159
- For standalone use, run `ruby_llm-agent-evaluations db:migrate` once before the first evaluation. Rails applications install the single engine migration and run `bin/rails db:migrate` as shown above.
160
-
161
- The single migration creates exactly three evaluation tables: `ruby_llm_evaluations_runs`, `ruby_llm_evaluations_case_results`, and `ruby_llm_evaluations_metric_results`. It creates `minimum_scores` directly, with the foreign keys and indexes needed for history and baseline comparisons. No legacy tables, payload imports, or custom schema-version tables are required or inspected. ActiveRecord manages its ordinary migration bookkeeping. Execution uses the host connection pool and does not establish, migrate, or disconnect it.
105
+ ```ruby
106
+ # Inside your agent; ui_payload comes from your application's renderer.
107
+ RubyLLM::Evaluations::Response.new(
108
+ output: "Returns within 30 days",
109
+ components: [{ protocol: "a2ui", version: "0.8", payload: ui_payload }]
110
+ )
111
+ ```
162
112
 
163
- Existing PostgreSQL installations continue using their applied migration and saved history unchanged; no schema reset or history deletion is needed. Rails migrations already copied into a host are not automatically updated when the gem changes. New hosts should install the current engine migration; database portability does not require a legacy upgrade migration.
113
+ Evaluators read `context.response.components`; history and reports retain the evidence without new tables or migrations.
164
114
 
165
- ~~~ruby
166
- # config/routes.rb
167
- Rails.application.routes.draw do
168
- mount RubyLLM::Evaluations::Engine => "/evaluations"
115
+ ```ruby
116
+ # Build the check separately; expected_ui_messages is parsed JSON in question metadata.
117
+ ui_check = RubyLLM::Evaluations::Evaluators.response_components(protocol: "a2ui", version: "0.8") do |components, context|
118
+ actual_messages = components.flat_map do |component|
119
+ component.fetch("payload").lines.map { |line| JSON.parse(line) }
120
+ end
121
+ actual_messages == context.metadata.fetch(:expected_ui_messages)
169
122
  end
170
- ~~~
171
-
172
- Visit /evaluations. Assets, filters, detail/baseline links, and downloads respect the mount path. The dashboard has **no built-in authentication**: protect it with application authorization or a trusted reverse proxy before exposing sensitive data. It accepts GET/HEAD only.
173
-
174
- ## Offline quick start: exact match, not semantic judging
175
-
176
- Requirements: Ruby 3.2+, Bundler, and your chosen database driver (`sqlite3` for this quick start). Rails and RubyLLM are optional. This deterministic local demo runs without API keys and is deliberately separate from semantic judging. It performs a real local answer lookup and records that action; exact match is not a semantic judge.
177
-
178
- From this checkout, the development bundle supplies the drivers. Standalone consumers add the gem and their chosen driver to their own Gemfile, then use `bundle exec ruby_llm-agent-evaluations` in place of the checkout command below.
179
-
180
- ~~~sh
181
- bundle install
182
- unset DATABASE_URL # Defaults to sqlite3:ruby_llm_evaluations.sqlite3 in the current directory.
183
-
184
- # Migrate once before executing specs. Runs never create tables implicitly.
185
- bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations db:migrate
186
- bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/support_eval.rb --metadata '{"revision":"reference"}'
187
-
188
- # Exit 1 is intentional: incorrect answer and wrong lookup arguments.
189
- AGENT_REVISION=regressed bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/support_eval.rb --fail-on-regression --metadata '{"revision":"regressed"}'
190
123
 
191
- bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations history
192
- bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations dashboard
193
- ~~~
124
+ # Inside your suite:
125
+ evaluate :a2ui, using: ui_check
126
+ ```
194
127
 
195
- Open **http://127.0.0.1:4567**; Ctrl-C stops the server. The local SQLite file preserves evidence across restarts. Set DATABASE_URL to use another database or SQLite file; migrations remain an explicit setup step.
128
+ The evaluator rejects missing components, an empty matching set, or a wrong declared version before your check runs. Your block validates the schema and application contract; the gem does **not** certify A2UI compliance or render supplied UI. Payloads preserve their original JSON-compatible shape; JSON/JSONL strings are not automatically parsed.
196
129
 
197
- ### PostgreSQL or MySQL instead
130
+ Text is optional (`output: nil`). Components `nil` means unreported, `[]` means explicitly none. Put expected UI data in question metadata and bump suite `version` when validators change. `response_components` in response metadata is reserved for persistence.
198
131
 
199
- Install `pg` for PostgreSQL or `mysql2` for MySQL in your consumer Gemfile. For local services from this checkout:
132
+ **Only for actual tools:** if an existing tool emits a response component, opt into capture inside that tool class:
200
133
 
201
- ~~~sh
202
- docker compose up -d postgres mysql
134
+ ```ruby
135
+ prepend RubyLLM::Evaluations::ResponseComponentTool
203
136
 
204
- # PostgreSQL: the development database is provisioned by Compose.
205
- export DATABASE_URL='postgresql://postgres@127.0.0.1:55432/ruby_llm_evaluations'
206
-
207
- # Or MySQL: create a development database, then select it.
208
- docker compose exec -T mysql mysql -uroot -pdev-only -e 'CREATE DATABASE IF NOT EXISTS ruby_llm_evaluations;'
209
- export DATABASE_URL='mysql2://root:dev-only@127.0.0.1:53306/ruby_llm_evaluations'
210
-
211
- # With the selected URL, migrate once, then run the same demo.
212
- bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations db:migrate
213
- bundle exec ruby -Ilib exe/ruby_llm-agent-evaluations run examples/support_eval.rb
214
- ~~~
215
-
216
- Compose binds passwordless PostgreSQL to loopback port 55432 and MySQL 8.4 with the development-only root password `dev-only` to loopback port 53306. **Do not use these credentials/configurations in production.** Named volumes preserve database history; `docker compose stop` does not delete it. For your own service, set DATABASE_URL to an existing database with an appropriate application account.
217
-
218
- The offline example produces the following evidence:
137
+ def response_component
138
+ { protocol: "a2ui", version: "0.8" }
139
+ end
140
+ ```
219
141
 
220
- | Revision | Passing questions | Quality score | CLI exit |
221
- | --- | --- | --- | --- |
222
- | Reference | 3 of 3 | 1.000 | 0 |
223
- | Regressed | 2 of 3 | 0.667 | 1 |
142
+ Marked tools keep their return values and callbacks. `Evaluations.run` and the RubyLLM chat adapter capture results in call order with protocol, declared version, tool name, arguments, and payload; explicit final-response components follow them. Outside a run, use `Response.capture { agent.call(question) }`. Tool capture is synchronous and fiber-local; worker-thread/streaming agents assemble components explicitly. Plain renderer output has no fabricated tool provenance.
224
143
 
225
- The regressed agent answers the return question with 7 days instead of 30 and looks up shipping rather than returns. Both the answer check and observed-action check fail.
144
+ See the [complete offline A2UI 0.8 example](examples/a2ui_eval.rb), which uses a plain renderer and needs no RubyLLM dependency: run normally, then with `AGENT_REVISION=regressed` or `AGENT_REVISION=missing_ui`. Correct text still passes; incorrect/missing UI fails.
226
145
 
227
- The standalone CLI alone establishes ActiveRecord::Base's connection. Standalone library callers configure it themselves:
146
+ ## API replay
228
147
 
229
- ~~~ruby
230
- ActiveRecord::Base.establish_connection(ENV.fetch("DATABASE_URL", "sqlite3:ruby_llm_evaluations.sqlite3"))
231
- RubyLLM::Evaluations.migrate! # Once during database setup, not per run.
232
- ~~~
148
+ Keep API data fixed while exercising the real HTTP client. Save as `shipping_eval.rb` and run with the same CLI:
233
149
 
234
- ## Custom checks and action traces
150
+ ```ruby
151
+ require "net/http"
152
+ require "json"
153
+ require "ruby_llm/evaluations"
235
154
 
236
- Explicit suite-level evaluators remain available for custom scoring and become defaults for questions without minimum_scores:
155
+ E = RubyLLM::Evaluations
156
+ agent = ->(_question) do
157
+ response = Net::HTTP.get_response(URI("https://shipping.example/orders?id=42"))
158
+ JSON.parse(response.body).fetch("status")
159
+ end
237
160
 
238
- ~~~ruby
239
- RubyLLM::Evaluations.define("Concise answers", agent: your_agent) do
240
- evaluate :conciseness, threshold: 0.8 do |output, context|
241
- score = output.split.size <= context.metadata.fetch(:max_words) ? 1.0 : 0.0
242
- RubyLLM::Evaluations::Assessment.new(score: score, reason: "Checked word budget")
161
+ E.define("Shipping", agent: agent, agent_name: "ShippingAgent") do
162
+ evaluate :exact_match, using: E::Evaluators.exact_match
163
+ question "Track order 42", expected_answer: "ready" do
164
+ api_interactions do
165
+ expect_request(method: :get, url: "https://shipping.example/orders", query: { id: "42" })
166
+ .respond_with(json: { status: "ready" })
167
+ end
243
168
  end
244
- question "Explain returns briefly", expected_answer: "Return within 30 days.",
245
- minimum_scores: { conciseness: 0.8 }, metadata: { max_words: 12 }
246
169
  end
247
- ~~~
170
+ ```
248
171
 
249
- Evaluators receive (output, context). Context exposes id, name, question, expected_answer, expected_actions, minimum_scores, metadata, question_data, response, and the suite's judge agent class. Return Boolean, a finite score in [0,1], or an Assessment. Deterministic built-ins include exact_match, contains, matches(/pattern/), and valid_json; register them with evaluate(name, using:, threshold:). Evaluators.llm_judge(rubric:) supports custom rubrics and uses the constructor judge; without a judge it is skipped.
172
+ For large responses, replace `json:` with `body_file: "fixtures/order.json"`. Files resolve relative to the spec; exact bytes are snapshotted and fingerprinted. Invalid/missing files fail before execution. See [inline and file fixtures](examples/api_replay_eval.rb).
250
173
 
251
- Select action_group in question minimum_scores and declare the expected invocation trace:
174
+ Requests must match method, URL, query, typed JSON body, and declared headers **once, in order**. Unexpected, wrong, missing, or exhausted requests fail the case—even if rescued by the agent. JSON `1`, `"1"`, `false`, and `null` stay distinct; omitting request `json:` requires an empty body.
252
175
 
253
- ~~~ruby
254
- question "Track order 42", expected_answer: "Order 42 has shipped.",
255
- minimum_scores: { action_group: 1.0 },
256
- expected_actions: [{ group: "orders", name: "lookup_order", arguments: { id: 42 } }]
257
- ~~~
176
+ Replay wraps agent execution, supports WebMock-compatible clients, and persists redacted mismatch evidence. Seed database tools separately; there is no recording mode. Explicit `allow_hosts: ["api.openai.com"]` permits live model traffic without bypassing fixtures. Judges run outside replay.
258
177
 
259
- The agent must return **observed**, not assumed, calls:
178
+ **Use a dedicated process:** WebMock interception is process-global, although replay cases serialize and restore caller state.
260
179
 
261
- ~~~ruby
262
- RubyLLM::Evaluations::Response.new(
263
- output: "Order 42 has shipped.",
264
- actions: [{ group: "orders", name: "lookup_order", arguments: { id: 42 } }],
265
- input_tokens: 120, output_tokens: 18, cost: 0.0002,
266
- metadata: { model: "your-model" }
267
- )
268
- ~~~
180
+ ## Reports and dashboard
269
181
 
270
- Group, name, arguments, and order must match exactly. Missing/incorrect/extra calls reduce the score. Expected actions require groups. actions: nil means missing instrumentation and produces an error; actions: [] honestly means observed no actions. A narrative promise is never evidence of execution.
182
+ ```sh
183
+ bundle exec ruby_llm-agent-evaluations run support_eval.rb --fail-on-regression --tolerance 0.02
184
+ bundle exec ruby_llm-agent-evaluations history --suite 'Return policy' --limit 50
185
+ bundle exec ruby_llm-agent-evaluations report --format html --output report.html
186
+ bundle exec ruby_llm-agent-evaluations report RUN_ID --baseline BASELINE_ID --format json
187
+ ```
271
188
 
272
- The RubyLLM adapter captures calls with action_groups: { "lookup_order" => "orders" }; unknown groups remain nil, not invented. Its factory must return a fresh chat for each call. Plain RubyLLM messages normalize output/usage but do not contain preceding action traces. Generic callables are reused: create fresh state inside the callable when needed. Runner copies question data so agent mutation cannot alter the suite.
273
-
274
- ## Persisted evidence and compatible baselines
275
-
276
- ~~~ruby
189
+ ```ruby
277
190
  run = RubyLLM::Evaluations.run(suite, metadata: { revision: "candidate" })
278
- # An actual RubyLLM::Evaluations::Run ActiveRecord record.
279
191
  report = RubyLLM::Evaluations::Report.new(run, baseline: run.previous, tolerance: 0.02)
280
- report.export("agent-report.html")
281
-
282
- RubyLLM::Evaluations::Run.recent.for_suite("Customer support").with_results
283
- run.case_results.first.metric_results
284
- snapshot = run.snapshot # Detached JSON-portable evidence, not a persistence API.
285
- ~~~
192
+ report.export("report.html") # Also .json, .md, or .txt; export writes the file.
193
+ ```
286
194
 
287
- Report#export writes the file itself and returns its path. The extension chooses HTML (.html/.htm), JSON (.json), Markdown (.md/.markdown), or text (.txt/.text). Use report.export("agent-report", format: :html) when the filename has no recognized extension. Unknown formats fail before writing; file-system errors propagate. String-returning to_html/to_json/to_markdown/to_text remain available for in-memory use.
195
+ Runs have immutable UUIDs and append-only model APIs. Baselines are chronological, never from the future. Invalid/incompatible explicit baselines fail before agent execution; tolerance never hides newly failed thresholds or errors.
288
196
 
289
- Run, CaseResult, and MetricResult records are readonly after persistence through normal ActiveRecord APIs. Runner finishes agent/judge calls before the short atomic save transaction; errors are recorded and other questions/checks continue. Runner.new.run(suite, metadata: {}) is also available.
197
+ CLI exits: **0** no failed checks/gated regression, **1** failed/errored checks or gated regression, **2** invalid input/configuration. Exit 0 does not establish quality when all metrics were skipped. JSON exports contain the run plus `comparison`; usage totals cover reported agent usage, not judge usage.
290
198
 
291
- Baselines are earlier runs matching suite, agent, and typed definition fingerprint, with database-generated sequence breaking timestamp ties. Fingerprints cover question data, expected answers/actions, metric names and thresholds in minimum_scores, metadata, and explicit suite version. Changing minimum_scores changes the definition fingerprint, so different threshold sets are not automatically compared. Renaming the API alone preserves existing definition fingerprints and baseline history. **Bump version when changing evaluator implementations, rubrics, or judge configuration**; callable source is not hashed. Keep agent identity stable across revisions and record revision/model/prompt information in run metadata.
199
+ The dashboard exposes filters, scores, baseline comparisons, case evidence, and JSON downloads. HTML reports escape output and need no external assets. This capture shows persisted offline evaluations, not live model reasoning:
292
200
 
293
- A question passes only when at least one metric was calculated and every calculated metric passes without error. With no calculated metrics it is not_evaluated. Case quality averages calculated metric scores (metric errors contribute zero); agent errors yield zero. Run quality and pass rate exclude not-evaluated questions and are null when no questions were evaluated. Completed status means execution finished, not success. Token/cost totals include only reported agent usage, not judge usage; zero totals do not prove zero cost. Response costs are USD for dashboard display.
201
+ ![Evaluation history, quality trends, and baseline changes](docs/images/dashboard-history.png)
294
202
 
295
- Regression comparison is separate from minimum-score passing: it compares later observed scores against a previous run of the same definition. Use --fail-on-regression to make regressions fail the CLI gate and --tolerance to allow small score decreases. Regressions include score decreases beyond tolerance, newly failed thresholds, and new errors. Tolerance does not hide new failures/errors. Missing/incompatible explicit baselines are rejected before agent execution; automatic historical baselines never come from the future.
203
+ <details>
204
+ <summary>More screenshots</summary>
296
205
 
297
- For example, with a semantic-accuracy minimum of 0.9, a previous score of 0.98 and a later score of 0.92 both pass the minimum. The later run nevertheless regresses by 0.06 when tolerance is 0.02, so --fail-on-regression fails the gate. On the first run, the same minimum applies but there is no previous score to compare.
206
+ ![Regression detail](docs/images/dashboard-regression.png)
207
+ ![Answers and observed actions](docs/images/dashboard-case.png)
208
+ ![Standalone HTML report](docs/images/html-report.png)
209
+ ![Mobile dashboard](docs/images/dashboard-mobile.png)
210
+ ![Without a judge: unavailable quality, no invented scores](docs/images/dashboard-without-judge.png)
298
211
 
299
- ~~~sh
300
- ruby_llm-agent-evaluations run your_eval.rb --fail-on-regression --tolerance 0.02
301
- ruby_llm-agent-evaluations run your_eval.rb --baseline RUN_ID --fail-on-regression
302
- ruby_llm-agent-evaluations history --suite 'Customer support' --agent SupportAgent --limit 50
303
- ruby_llm-agent-evaluations report --format html --output agent.html
304
- ruby_llm-agent-evaluations report --format markdown --output agent.md
305
- ruby_llm-agent-evaluations report RUN_ID --baseline BASELINE_ID --format json
306
- ~~~
212
+ </details>
307
213
 
308
- CLI exits: **0** no failed/errored calculated checks or regression gate, **1** failed/errored checks or detected gated regression, **2** invalid specification/options/configuration/baseline. Exit 0 does not claim quality when all checks were skipped; inspect not_evaluated and null scores. HTML reports escape output and need no external assets. JSON contains the top-level run snapshot plus comparison, with per-case metrics (not a run wrapper). The dashboard offers JSON downloads.
214
+ ## Examples
309
215
 
310
- ## Dashboard evidence
216
+ | Example | Purpose | Provider calls |
217
+ | --- | --- | --- |
218
+ | [Offline support](examples/support_eval.rb) | Answers, actions, regression gating. | None |
219
+ | [API replay](examples/api_replay_eval.rb) | Inline/file fixtures and request arguments. | None |
220
+ | [RubyLLM judging](examples/ruby_llm_eval.rb) | Semantic accuracy and relevancy. | Paid; opt-in |
221
+ | [Semantic + actions](examples/semantic_actions_eval.rb) | Answer and tool correctness together. | Paid; opt-in |
222
+ | [Context bloat](examples/context_bloat_eval.rb) | ~500 KB fixture, tool arguments, 4,096-byte tool-result budget. | Paid; opt-in |
311
223
 
312
- These current screenshots were captured from persisted offline PostgreSQL runs and generated reports. They show deterministic exact-match/action checks, real history, and regression evidence—not live model reasoning.
224
+ The bloat example measures **bytes**, not tokens or universal context-window fit. Scripted-provider tests prove transport, not model reasoning. Bundled RubyLLM adapters/judges target **1.x (1.14.1+)**; 2.x adapter compatibility is not claimed. Generic callables do not require RubyLLM.
313
225
 
314
- ![Evaluation history and quality trends](docs/images/dashboard-history.png)
315
- ![Regression report](docs/images/dashboard-regression.png)
316
- ![Case answer and observed actions](docs/images/dashboard-case.png)
317
- ![Standalone HTML report](docs/images/html-report.png)
318
- ![Narrow dashboard](docs/images/dashboard-mobile.png)
226
+ ## Safety
319
227
 
320
- With no judge supplied, the actual dashboard records answers but leaves quality unavailable and draws no invented trend. This capture uses RubyLLM against a local scripted HTTP provider, not live model reasoning:
228
+ Specs execute trusted Ruby. Evaluation data persists: use synthetic fixtures, redact sensitive information, and manage retention/backups. Never commit credentials, production data, databases, or sensitive reports. Judges are nondeterministic; calibrate against human review. Prompt injection remains a risk. The standalone dashboard is loopback-bound and GET/HEAD-only by default.
321
229
 
322
- ![Persisted answers without a judge: unavailable quality and no fabricated scores](docs/images/dashboard-without-judge.png)
230
+ <details>
231
+ <summary>Development and publishing</summary>
323
232
 
324
- ## Development and packaging
233
+ Install native client headers required by `pg`/`mysql2`, then from this checkout:
325
234
 
326
- Integration tests always: behavior changes must update end-to-end coverage. Exercise real SQLite, PostgreSQL, and MySQL persistence and consumer smoke flows; integration tests must never silently skip an unavailable database. These are development-check requirements, not a requirement that consumers install every driver or run every server.
327
-
328
- ~~~sh
235
+ ```sh
329
236
  bundle install
330
- docker compose up -d postgres mysql
237
+ docker compose up -d --wait postgres mysql
238
+ # Once, if the test database does not exist:
331
239
  docker compose exec -T postgres createdb -U postgres ruby_llm_evaluations_test
332
240
  export TEST_DATABASE_URL='postgresql://postgres@127.0.0.1:55432/ruby_llm_evaluations_test'
333
241
  export TEST_MYSQL_DATABASE_URL='mysql2://root:dev-only@127.0.0.1:53306/mysql'
334
242
  bundle exec rake integration
335
243
  bundle exec rake test
244
+ ```
336
245
 
337
- gem build ruby_llm-agent-evaluations.gemspec
338
- gem install ./ruby_llm-agent-evaluations-0.2.2.gem
339
- ruby_llm-agent-evaluations --help
340
- ~~~
341
-
342
- Tests isolate temporary SQLite files, PostgreSQL schemas, and MySQL databases rather than clear development history. The PostgreSQL test role needs schema-creation privileges; the MySQL test account needs database-creation/deletion privileges on the test server. SQLite needs no service or test URL. Local HTTP provider fixtures verify real adapter transport/orchestration, not model reasoning. Default tests never call paid providers. For UI changes inspect desktop/narrow layouts, keyboard access, filters, chart navigation, and downloads. Shared engineering guidance and specialist skills live in [.agents/](.agents/AGENTS.md).
343
-
344
- ## Publishing
246
+ Tests isolate SQLite files, PostgreSQL schemas, and MySQL databases; test accounts need creation privileges. Default tests never call paid providers. Consumers need only their chosen driver. Compose credentials are **development-only**, bound to loopback. See [engineering guidance](.agents/AGENTS.md).
345
247
 
346
- The release version is defined in `lib/ruby_llm/evaluations/version.rb`. Bump it and update CHANGELOG before publishing; RubyGems does not allow replacing an existing version.
248
+ To publish, bump `lib/ruby_llm/evaluations/version.rb`, update [CHANGELOG](CHANGELOG.md), then:
347
249
 
348
- ~~~sh
349
- export TEST_DATABASE_URL='postgresql://postgres@127.0.0.1:55432/ruby_llm_evaluations_test'
350
- export TEST_MYSQL_DATABASE_URL='mysql2://root:dev-only@127.0.0.1:53306/mysql'
351
- bundle install
250
+ ```sh
352
251
  bundle exec ruby script/publish --dry-run
353
-
354
- # Authenticate locally; never paste or commit an API key.
355
252
  gem signin
356
- # Commit the version, changelog, and release changes before upload.
253
+ # Commit release changes before uploading.
357
254
  bundle exec ruby script/publish
358
- ~~~
359
-
360
- The script runs integration tests and the full suite, builds the gem, rejects private paths in its manifest, installs into a temporary GEM_HOME, and executes inline/file API replay against an isolated PostgreSQL schema outside the checkout. It verifies persisted evidence and argument-mismatch regression. Temporary package, installation, and schema are removed afterward. `--dry-run` runs these gates without credentials or upload. Normal publication requires a clean committed working tree and uploads only to `https://rubygems.org`; existing RubyGems credentials or `GEM_HOST_API_KEY` are supported. Credentials need permission to push this gem; RubyGems may additionally request MFA. No version bumps, git commits, tags, or pushes happen implicitly.
361
-
362
- RubyLLM remains optional. The development bundle and provider-backed examples currently target RubyLLM 1.x; this release does not claim RubyLLM 2.x adapter compatibility. Default release checks do not call paid providers.
363
-
364
- ## Data and security
255
+ ```
365
256
 
366
- Specs execute trusted code. Answers and judge data are untrusted. Prompts, references, traces, errors, and metadata persist in the database and exports: redact sensitive information and manage retention/backups. Never commit credentials or production data. No hosted backend, external dashboard assets, framework provider retries, or automatic-success fallbacks are supplied.
257
+ The script gates upload on tests, package checks, and installed-gem replay/persistence smoke. Dry runs upload nothing. Publication requires a clean committed tree and push-authorized RubyGems credentials (`GEM_HOST_API_KEY` supported); MFA may apply. Versions cannot be replaced. Git commits/tags/pushes are not automatic.
367
258
 
368
- ## License
259
+ </details>
369
260
 
370
- MIT. See [LICENSE](LICENSE).
261
+ [MIT license](LICENSE) · [Changelog](CHANGELOG.md) · [RubyGems](https://rubygems.org/gems/ruby_llm-agent-evaluations)
@@ -18,13 +18,17 @@ module RubyLLM
18
18
  validates :duration_ms, numericality: { greater_than_or_equal_to: 0 }
19
19
  validate :validate_json
20
20
 
21
+ def components
22
+ response_metadata&.fetch("response_components", nil)
23
+ end
24
+
21
25
  def snapshot
22
26
  Data.json_value(
23
27
  id: case_id, name: name, question: question, expected_answer: expected_answer,
24
28
  expected_actions: expected_actions, minimum_scores: minimum_scores,
25
29
  metadata: metadata, output: output, status: status, duration_ms: duration_ms,
26
30
  input_tokens: input_tokens, output_tokens: output_tokens, cost: cost, actions: actions,
27
- response_metadata: response_metadata, error: error_details,
31
+ components: components, response_metadata: response_metadata&.except("response_components"), error: error_details,
28
32
  metrics: metric_results.map(&:snapshot)
29
33
  )
30
34
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Offline consumer example, not proof of model reasoning. No RubyLLM dependency.
4
+ require "ruby_llm/evaluations"
5
+
6
+ class RenderReturnPolicy
7
+ def call(days:)
8
+ [
9
+ JSON.generate(surfaceUpdate: { surfaceId: "returns", components: [
10
+ { id: "root", component: { Text: { text: { literalString: "Returns within #{days} days" } } } }
11
+ ] }),
12
+ JSON.generate(beginRendering: { surfaceId: "returns", root: "root" })
13
+ ].join("\n")
14
+ end
15
+ end
16
+
17
+ agent = lambda do |_question|
18
+ components = []
19
+ unless ENV["AGENT_REVISION"] == "missing_ui"
20
+ days = ENV["AGENT_REVISION"] == "regressed" ? 7 : 30
21
+ components << { protocol: "a2ui", version: "0.8", payload: RenderReturnPolicy.new.call(days: days) }
22
+ end
23
+ RubyLLM::Evaluations::Response.new(
24
+ output: "Returns within 30 days", # Correct text must not conceal incorrect/missing UI.
25
+ components: components
26
+ )
27
+ end
28
+
29
+ # Expected UI is question data, independent of the tool implementation.
30
+ expected_ui_messages = [
31
+ { "surfaceUpdate" => { "surfaceId" => "returns", "components" => [
32
+ { "id" => "root", "component" => { "Text" => { "text" => { "literalString" => "Returns within 30 days" } } } }
33
+ ] } },
34
+ { "beginRendering" => { "surfaceId" => "returns", "root" => "root" } }
35
+ ]
36
+
37
+ # Application-specific contract, not a complete A2UI schema validator.
38
+ ui_check = RubyLLM::Evaluations::Evaluators.response_components(protocol: "a2ui", version: "0.8") do |components, context|
39
+ next false unless components.one?
40
+
41
+ actual_messages = components.first.fetch("payload").lines.map { |line| JSON.parse(line) }
42
+ actual_messages == context.metadata.fetch(:expected_ui_messages)
43
+ end
44
+
45
+ RubyLLM::Evaluations.define("A2UI return policy", agent: agent, agent_name: "ReturnPolicyAgent", version: "1") do
46
+ evaluate :text, using: RubyLLM::Evaluations::Evaluators.exact_match
47
+ evaluate :a2ui, using: ui_check
48
+ question "Show the return policy",
49
+ expected_answer: "Returns within 30 days",
50
+ minimum_scores: { text: 1.0, a2ui: 1.0 },
51
+ metadata: { protocol: "a2ui", protocol_version: "0.8", expected_ui_messages: expected_ui_messages }
52
+ end
@@ -43,7 +43,7 @@ module ContextBloatExample
43
43
  adapter = E::Adapters.ruby_llm_chat(action_groups: { return_policy: "returns" }) { chat }
44
44
  response = adapter.call(question)
45
45
  tool_bytes = chat.messages.select { |message| message.role == :tool }.map { |message| message.content.to_s.bytesize }
46
- E::Response.new(output: response.output, actions: response.actions,
46
+ E::Response.new(output: response.output, actions: response.actions, components: response.components,
47
47
  input_tokens: response.input_tokens, output_tokens: response.output_tokens, cost: response.cost,
48
48
  metadata: response.metadata.merge("tool_result_bytes" => tool_bytes,
49
49
  "api_response_bytes" => tool.api_response_bytes))
@@ -15,7 +15,7 @@ module RubyLLM
15
15
  end
16
16
  lambda do |input|
17
17
  chat = factory.call
18
- answer = Response.normalize(chat.ask(input))
18
+ answer = Response.capture { chat.ask(input) }
19
19
  messages = chat.messages
20
20
  actions = messages.flat_map do |message|
21
21
  (message.tool_calls || {}).each_value.map do |call|
@@ -25,7 +25,8 @@ module RubyLLM
25
25
  assistant_messages = messages.select { |message| message.role == :assistant }
26
26
  Response.new(output: answer.output, metadata: answer.metadata, cost: answer.cost,
27
27
  input_tokens: total_tokens(assistant_messages, :input_tokens),
28
- output_tokens: total_tokens(assistant_messages, :output_tokens), actions: actions)
28
+ output_tokens: total_tokens(assistant_messages, :output_tokens), actions: actions,
29
+ components: answer.components || [])
29
30
  end
30
31
  end
31
32
 
@@ -47,6 +47,7 @@
47
47
  <section><h4><%= label %></h4><% if item[key].nil? %><p class="muted"><%= missing %></p><% elsif item[key].empty? %><p class="muted"><%= empty %></p><% else %><p class="muted small">Ordered trace: group, name, and arguments.</p><ol class="evidence-list"><% item[key].each do |action| %><li><pre class="payload"><%= ui.h(ui.pretty(action)) %></pre></li><% end %></ol><% end %></section>
48
48
  <% end %>
49
49
  </div>
50
+ <details class="metadata-details"><summary>Response components</summary><pre class="payload"><%= ui.h(item["components"].nil? ? "Unavailable — response component instrumentation missing" : ui.pretty(item["components"])) %></pre></details>
50
51
  <details class="metadata-details"><summary>Response metadata</summary><pre class="payload"><%= ui.h(item["response_metadata"].nil? ? "Unavailable — response metadata not reported" : ui.pretty(item["response_metadata"])) %></pre></details>
51
52
  <% unless item["metadata"].empty? %><details class="metadata-details"><summary>Case metadata</summary><pre class="payload"><%= ui.h(ui.pretty(item["metadata"])) %></pre></details><% end %>
52
53
  </article><% end %>
@@ -36,6 +36,10 @@ module RubyLLM
36
36
  end
37
37
  end
38
38
 
39
+ def snapshot(value)
40
+ JSON.parse(JSON.generate(json_value(value)))
41
+ end
42
+
39
43
  def canonical(value)
40
44
  case value
41
45
  when Hash
@@ -36,6 +36,26 @@ module RubyLLM
36
36
  end
37
37
  end
38
38
 
39
+ def response_components(protocol:, version: nil, &check)
40
+ ResponseComponents.validate_label!(protocol, "component protocol")
41
+ ResponseComponents.validate_label!(version, "component version") unless version.nil?
42
+ raise ArgumentError, "provide a response component check block" unless check
43
+ lambda do |_output, context|
44
+ components = context.response.components
45
+ if components.nil?
46
+ next Assessment.new(score: false, reason: "Response component instrumentation missing")
47
+ end
48
+ matching = components.select { |item| item["protocol"] == protocol }
49
+ if matching.empty?
50
+ next Assessment.new(score: false, reason: "No #{protocol} response components")
51
+ end
52
+ if version && matching.any? { |item| item["version"] != version }
53
+ next Assessment.new(score: false, reason: "Unexpected #{protocol} protocol version")
54
+ end
55
+ check.call(matching, context)
56
+ end
57
+ end
58
+
39
59
  def semantic_accuracy
40
60
  JudgeMetric.new(reference: true, rubric: <<~RUBRIC)
41
61
  Score factual semantic agreement with the expected reference, not lexical overlap.
@@ -223,6 +223,7 @@ module RubyLLM
223
223
  "Minimum passing scores" => item["minimum_scores"],
224
224
  "Expected actions (ordered)" => item["expected_actions"] || "Unavailable — expected actions not specified",
225
225
  "Observed actions (ordered)" => item["actions"] || "Unavailable — action instrumentation missing",
226
+ "Response components (ordered)" => item["components"] || "Unavailable — response component instrumentation missing",
226
227
  "Response metadata" => item["response_metadata"].nil? ? "Unavailable — response metadata not reported" : item["response_metadata"],
227
228
  "Metadata" => item["metadata"], "Duration (ms)" => item["duration_ms"],
228
229
  "Input tokens" => item["input_tokens"], "Output tokens" => item["output_tokens"],
@@ -3,9 +3,9 @@
3
3
  module RubyLLM
4
4
  module Evaluations
5
5
  class Response
6
- attr_reader :output, :input_tokens, :output_tokens, :cost, :metadata, :actions
6
+ attr_reader :output, :input_tokens, :output_tokens, :cost, :metadata, :actions, :components
7
7
 
8
- def initialize(output:, input_tokens: nil, output_tokens: nil, cost: nil, metadata: {}, actions: nil)
8
+ def initialize(output:, input_tokens: nil, output_tokens: nil, cost: nil, metadata: {}, actions: nil, components: nil)
9
9
  @output = output
10
10
  @input_tokens = tokens(input_tokens)
11
11
  @output_tokens = tokens(output_tokens)
@@ -13,9 +13,33 @@ module RubyLLM
13
13
  raise ArgumentError, "cost must be a finite nonnegative number"
14
14
  end
15
15
  raise ArgumentError, "response metadata must be a Hash" unless metadata.is_a?(Hash)
16
+ if metadata.key?(:response_components) || metadata.key?("response_components")
17
+ raise ArgumentError, "response_components is reserved; use components: instead"
18
+ end
16
19
  @cost = cost&.to_f
17
20
  @metadata = Data.json_value(metadata)
18
21
  @actions = actions.nil? ? nil : Actions.normalize(actions)
22
+ @components = components.nil? ? nil : ResponseComponents.normalize(components)
23
+ end
24
+
25
+ def self.capture
26
+ capture = ResponseComponentCapture.new
27
+ capture.around do
28
+ response = normalize(yield)
29
+ if capture.components.empty?
30
+ response
31
+ else
32
+ new(
33
+ output: response.output,
34
+ input_tokens: response.input_tokens,
35
+ output_tokens: response.output_tokens,
36
+ cost: response.cost,
37
+ metadata: response.metadata,
38
+ actions: response.actions,
39
+ components: capture.components + (response.components || [])
40
+ )
41
+ end
42
+ end
19
43
  end
20
44
 
21
45
  def self.normalize(value)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Evaluations
5
+ # Collects marked tool results during one synchronous agent invocation.
6
+ class ResponseComponentCapture
7
+ attr_reader :components
8
+
9
+ def self.current
10
+ Thread.current[:ruby_llm_evaluations_component_capture]
11
+ end
12
+
13
+ def initialize
14
+ @components = []
15
+ end
16
+
17
+ # Thread#[] is fiber-local; nested evaluations restore their enclosing capture.
18
+ def around
19
+ previous_capture = self.class.current
20
+ Thread.current[:ruby_llm_evaluations_component_capture] = self
21
+ yield
22
+ ensure
23
+ Thread.current[:ruby_llm_evaluations_component_capture] = previous_capture
24
+ end
25
+
26
+ def record_tool_call(tool, arguments)
27
+ arguments_snapshot = Data.snapshot(arguments || {})
28
+ result = yield
29
+
30
+ component = tool.response_component.merge(
31
+ tool_name: tool.name,
32
+ arguments: arguments_snapshot,
33
+ payload: tool_payload(result)
34
+ )
35
+ component_snapshot = Data.snapshot(component)
36
+ @components << ResponseComponents.normalize_component(component_snapshot)
37
+
38
+ result
39
+ end
40
+
41
+ private
42
+
43
+ def tool_payload(result)
44
+ if defined?(::RubyLLM::Tool::Halt) && result.is_a?(::RubyLLM::Tool::Halt)
45
+ result.content
46
+ else
47
+ result
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Evaluations
5
+ # Prepend to a tool that defines response_component; preserve its call result.
6
+ module ResponseComponentTool
7
+ def call(arguments)
8
+ capture = ResponseComponentCapture.current
9
+ return super unless capture
10
+
11
+ capture.record_tool_call(self, arguments) { super }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Evaluations
5
+ module ResponseComponents
6
+ module_function
7
+
8
+ def normalize(components)
9
+ raise ArgumentError, "components must be an array" unless components.is_a?(Array)
10
+
11
+ components.map { |component| normalize_component(component) }
12
+ end
13
+
14
+ def normalize_component(component)
15
+ raise ArgumentError, "each response component must be a Hash" unless component.is_a?(Hash)
16
+
17
+ component = Data.json_value(component)
18
+ unknown_fields = component.keys - %w[protocol version payload tool_name arguments]
19
+ unless unknown_fields.empty?
20
+ raise ArgumentError, "unknown response component fields: #{unknown_fields.join(', ')}"
21
+ end
22
+
23
+ validate_label!(component["protocol"], "component protocol")
24
+ validate_label!(component["version"], "component version") if component.key?("version")
25
+ validate_label!(component["tool_name"], "component tool_name") if component.key?("tool_name")
26
+ raise ArgumentError, "response component must include payload" unless component.key?("payload")
27
+ if component.key?("arguments") && !component["arguments"].is_a?(Hash)
28
+ raise ArgumentError, "component arguments must be a Hash"
29
+ end
30
+
31
+ component
32
+ end
33
+
34
+ def validate_label!(value, name)
35
+ unless value.is_a?(String) && !value.strip.empty?
36
+ raise ArgumentError, "#{name} must be a nonempty string"
37
+ end
38
+ value
39
+ end
40
+ end
41
+ end
42
+ end
@@ -43,11 +43,14 @@ module RubyLLM
43
43
  replay = ApiReplay.new(question.api_interactions) if question.api_interactions
44
44
  begin
45
45
  invoke = -> { suite.agent.call(mutable_copy(question.question)) }
46
- response = Response.normalize(replay ? replay.call(&invoke) : invoke.call)
46
+ response = Response.capture { replay ? replay.call(&invoke) : invoke.call }
47
47
  result.merge!("output" => Data.json_value(response.output),
48
48
  "input_tokens" => response.input_tokens, "output_tokens" => response.output_tokens,
49
49
  "cost" => response.cost, "response_metadata" => response.metadata,
50
50
  "actions" => Data.json_value(response.actions))
51
+ unless response.components.nil?
52
+ result["response_metadata"] = response.metadata.merge("response_components" => response.components)
53
+ end
51
54
  rescue StandardError => exception
52
55
  result["error"] = Data.error(exception)
53
56
  result["duration_ms"] = milliseconds(started)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Evaluations
5
- VERSION = "0.2.2"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -3,6 +3,9 @@
3
3
  require_relative "evaluations/version"
4
4
  require_relative "evaluations/data"
5
5
  require_relative "evaluations/actions"
6
+ require_relative "evaluations/response_components"
7
+ require_relative "evaluations/response_component_capture"
8
+ require_relative "evaluations/response_component_tool"
6
9
  require_relative "evaluations/judge_metric"
7
10
  require_relative "evaluations/evaluators"
8
11
  require_relative "evaluations/api_replay"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-agent-evaluations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - RubyLLM Evaluations contributors
@@ -79,6 +79,7 @@ files:
79
79
  - docs/images/dashboard-regression.png
80
80
  - docs/images/dashboard-without-judge.png
81
81
  - docs/images/html-report.png
82
+ - examples/a2ui_eval.rb
82
83
  - examples/api_replay_eval.rb
83
84
  - examples/context_bloat_eval.rb
84
85
  - examples/fixtures/large_return_policy.json
@@ -106,6 +107,9 @@ files:
106
107
  - lib/ruby_llm/evaluations/judge_metric.rb
107
108
  - lib/ruby_llm/evaluations/report.rb
108
109
  - lib/ruby_llm/evaluations/response.rb
110
+ - lib/ruby_llm/evaluations/response_component_capture.rb
111
+ - lib/ruby_llm/evaluations/response_component_tool.rb
112
+ - lib/ruby_llm/evaluations/response_components.rb
109
113
  - lib/ruby_llm/evaluations/runner.rb
110
114
  - lib/ruby_llm/evaluations/suite.rb
111
115
  - lib/ruby_llm/evaluations/version.rb