activeagent 1.4.0 → 1.5.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/CHANGELOG.md +285 -0
- data/lib/active_agent/evals/diagnosis.rb +28 -8
- data/lib/active_agent/evals/judge.rb +10 -3
- data/lib/active_agent/evals/publisher.rb +76 -0
- data/lib/active_agent/evals/report.rb +5 -2
- data/lib/active_agent/evals/report_html.rb +2 -2
- data/lib/active_agent/evals/runner.rb +60 -5
- data/lib/active_agent/evals/scenario_parser.rb +60 -10
- data/lib/active_agent/evals.rb +1 -0
- data/lib/active_agent/providers/_base_provider.rb +5 -1
- data/lib/active_agent/providers/mock/messages/base.rb +4 -2
- data/lib/active_agent/providers/open_ai/responses/transforms.rb +42 -11
- data/lib/active_agent/telemetry/instrumentation.rb +3 -0
- data/lib/active_agent/version.rb +1 -1
- metadata +4 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7401cdff6a7895238a082383f2ef32683b743946445e11dacbf377470bf31071
|
|
4
|
+
data.tar.gz: b0f847d04eedf620c7f5320950d65e5ffc712296ef7db2b7ea6be25f3bc433b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0a29cb99c3139f9c264eb6e86677d47ed592fb165c39489be2efc8ea2bb2dd1614bc8802643b812b04319b7356bc37177943bc9c2e0174de276555ba811ba426
|
|
7
|
+
data.tar.gz: 6fb392e7cb2f4381d79ae16567edca55d62c585b06f04a6a3da6b31cd0bb7e6149b2e67ceb7e5e143b04bc41123dd1d5bba14f38b33be6e21813e9923308d524
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,291 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.5.0] - 2026-09-10
|
|
11
|
+
|
|
12
|
+
Releases `activeagent` 1.5.0 and `actionagent` 1.5.0 from one tag.
|
|
13
|
+
|
|
14
|
+
`actionagent` goes from 1.3.0 to 1.5.0, skipping 1.4: the two gems are
|
|
15
|
+
released together from this repository and from one tag, and carrying one
|
|
16
|
+
version number across both is less confusing than explaining which
|
|
17
|
+
dashboard version pairs with which framework. `actionagent` 1.4 does not
|
|
18
|
+
exist and never will. The engine's floor on the framework
|
|
19
|
+
(`activeagent >= 1.4`) is unchanged and still correct.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **`ActiveAgent::Evals::Publisher` delivers a finished report to a
|
|
24
|
+
collector.** A run that already happened — in CI, in a host app's own
|
|
25
|
+
runtime, anywhere the evaluation core runs — can be sent to an
|
|
26
|
+
ActiveAgents-compatible collector without replaying the agent:
|
|
27
|
+
`Publisher.new(api_key:, endpoint:).call(report:, run_id:, source:,
|
|
28
|
+
agent_name:, suite:)` posts a version-1 envelope wrapping `Report#to_h`
|
|
29
|
+
(or the saved JSON hash of an earlier run) and returns the collector's
|
|
30
|
+
receipt. Delivery is synchronous, requires HTTPS outside loopback, does
|
|
31
|
+
not follow a redirect carrying the bearer credential, caps a request at
|
|
32
|
+
2 MiB, and raises `Publisher::Error` on anything but a receipt naming the
|
|
33
|
+
same `run_id` — so a retry with that same `run_id` and the saved report
|
|
34
|
+
re-delivers rather than re-runs. Publication is strictly opt-in and
|
|
35
|
+
happens only where an application writes the call: no configuration flag,
|
|
36
|
+
no callback, no default credential, and `api_key:` supplied explicitly at
|
|
37
|
+
the call site. That is deliberate, because the payload is the report
|
|
38
|
+
itself — every scenario's prompt, the agent's answers, and the tool calls
|
|
39
|
+
and their results — and whether that may leave the application is the
|
|
40
|
+
application's decision to make. Installing the gem sends nothing
|
|
41
|
+
anywhere. `docs/evals/publication.md` documents the envelope, the receipt
|
|
42
|
+
and the retry rules. (#414)
|
|
43
|
+
|
|
44
|
+
- **`Runner` takes `around_evaluation:` and `require_judge_scores:`.**
|
|
45
|
+
`around_evaluation:` is called with `(scenario, spec)` and a block, and
|
|
46
|
+
wraps the whole evaluation — the replay, the scoring, the judge calls
|
|
47
|
+
behind a recommendation — so a host can establish one trace context
|
|
48
|
+
across all of it and correlate a replay with the judging it triggered. It
|
|
49
|
+
must return the block's result; `on_result` runs after it returns, an
|
|
50
|
+
error it raises propagates to the caller, and `#evaluate` called directly
|
|
51
|
+
bypasses it, for a host doing its own scheduling. `require_judge_scores:`
|
|
52
|
+
(default `false`) settles what an unusable judge means. A judge that
|
|
53
|
+
raises or answers unscorably is skipped, and the scenario is then decided
|
|
54
|
+
on its rule scores alone — which reads as "the agent passed" when the
|
|
55
|
+
truth is "nobody graded the answer". Set it, and an otherwise passing
|
|
56
|
+
result whose `task_completion` or declared `llm_judge` criterion has no
|
|
57
|
+
usable score fails instead, with the new `judge_unavailable` fault naming
|
|
58
|
+
the unscored criteria and pointing at the judge's credentials, model and
|
|
59
|
+
JSON reply. A run with no judged criteria is unaffected. (#414)
|
|
60
|
+
|
|
61
|
+
- **A grouped suite imports as YAML or JSON, whole.** `ScenarioParser` read
|
|
62
|
+
a pasted list or a JSON array of scenarios; it now also reads the grouped
|
|
63
|
+
document `Suite` loads — `groups:` with per-group keys and display names,
|
|
64
|
+
scenarios carrying `key`, `prompt`, `notes`, `expect` and
|
|
65
|
+
`production_only` — from YAML or JSON, keeping every part of it.
|
|
66
|
+
`ScenarioParser.parse` and `.scenarios` gain `include_production_only:`,
|
|
67
|
+
which defaults to `true` to match `Suite`. The dashboard defaults it the
|
|
68
|
+
other way: post the document as `scenarios_text` and the production-only
|
|
69
|
+
questions stay out unless `include_production_only` is sent alongside it,
|
|
70
|
+
because those prompts run against a live agent. The choice is made at
|
|
71
|
+
import — the engine stores the scenarios it selected, not the source
|
|
72
|
+
document — so changing it means importing the document again. (#414)
|
|
73
|
+
|
|
74
|
+
- **`actionagent`: `ActionAgent.scenario_evaluation_adapter_resolver`.** A
|
|
75
|
+
host application with its own agent runtime can now run an evaluation
|
|
76
|
+
itself while keeping the dashboard's catalog, selection, jobs, result
|
|
77
|
+
persistence and report pages. The resolver is called with the persisted
|
|
78
|
+
evaluation and returns `nil` for the engine's normal `Agent#test_execute`
|
|
79
|
+
path, or a callable — `evaluation:`, `owner:`, `scenarios:`, `models:`,
|
|
80
|
+
`on_result:` — that runs the host's own agent and judge, yields every
|
|
81
|
+
result as it lands, and returns an `ActiveAgent::Evals::Report`. The
|
|
82
|
+
engine holds it to that contract: anything other than a `Report`, or a
|
|
83
|
+
report that omits or duplicates one of the selected scenario × model
|
|
84
|
+
pairs, fails the run rather than completing it with rows missing, and an
|
|
85
|
+
exception leaves the results already written in place. Dashboard
|
|
86
|
+
authentication, execution enablement and the host's execution quota still
|
|
87
|
+
apply. (#414)
|
|
88
|
+
|
|
89
|
+
- **`actionagent`: the Run Agent page is a conversation workbench.** Testing
|
|
90
|
+
an agent used to mean one prompt in, one output out, with no way to see —
|
|
91
|
+
or shape — what the model was given. The page now works the way a user
|
|
92
|
+
would work the agent: it pins a persisted conversation (a solid_agent
|
|
93
|
+
context) and every run sends that conversation's user and assistant turns
|
|
94
|
+
ahead of the new message, so follow-up questions actually follow up. The
|
|
95
|
+
context is editable in place — edit or delete a turn, seed a user or
|
|
96
|
+
assistant message without running, start a new conversation — and every
|
|
97
|
+
run is a fresh `AgentRun` with its own trace, so Traces and Interactions
|
|
98
|
+
see exactly what the model saw. Files attach to a message and ride along
|
|
99
|
+
through Active Storage (`AgentRun has_many_attached :attachments`, guarded
|
|
100
|
+
for hosts without it): images reach the model as vision input, PDFs as
|
|
101
|
+
documents, and text-like files (CSV, Markdown, JSON, plain text) are
|
|
102
|
+
inlined into the message; the persisted user message keeps an attachment
|
|
103
|
+
manifest so the conversation shows thumbnails afterwards. Assistant replies
|
|
104
|
+
can render **generative UI** — cards, stats, tables, charts, lists,
|
|
105
|
+
progress, forms, choice buttons, images, callouts and code — from a fenced
|
|
106
|
+
```` ```ui ```` JSON block in prose, a JSON reply whose top level is
|
|
107
|
+
`ui`/`blocks`, or the new `render_ui` tool (enable the **Generative UI**
|
|
108
|
+
tool on the agent). Forms and choices post their answer back into the
|
|
109
|
+
conversation as the next user message. New engine API: `GET/POST
|
|
110
|
+
/api/agents/:id/conversations`, message create/update/delete under
|
|
111
|
+
`/api/interactions/:id/messages`, multipart `POST /api/agents/:id/execute`
|
|
112
|
+
with `attachments[]` and `params[context_id]`, and attachment metadata on
|
|
113
|
+
run and message JSON. The reference host (`test/dummy`) gained the Active
|
|
114
|
+
Storage tables so the attachment path is exercised by the engine's tests.
|
|
115
|
+
Two notes for anyone driving that API directly: `execute`/`test` now answer
|
|
116
|
+
422 unless the request carries a prompt or a file, and per-run overrides in
|
|
117
|
+
`params` can no longer name `attachments` or `action` — those stay the
|
|
118
|
+
controller's to set. Model-supplied images in generative UI load on sight
|
|
119
|
+
only when they are inline data or this app's own URL; any other host is
|
|
120
|
+
offered as a click-to-load, since fetching one tells that host whatever the
|
|
121
|
+
model put in the URL.
|
|
122
|
+
|
|
123
|
+
### Fixed
|
|
124
|
+
|
|
125
|
+
- **A scenario passes only if it completed the task.** A scenario's verdict
|
|
126
|
+
was the mean of everything scored for it, and the judge's
|
|
127
|
+
`task_completion` grade was one number in that mean: an answer that
|
|
128
|
+
called the expected tool and contained the expected string could carry a
|
|
129
|
+
task grade of 0.2 to a mean of 0.73 and pass at the default threshold of
|
|
130
|
+
0.7. `task_completion` is a gate now — it has to reach `threshold` on its
|
|
131
|
+
own, and no number of passing tool and content checks can lift it — and
|
|
132
|
+
the fault names the number that failed: "Task completion scored 0.2
|
|
133
|
+
against a pass threshold of 0.7". An evaluation that configures its own
|
|
134
|
+
`llm_judge` criteria rather than relying on the implicit grade — which is
|
|
135
|
+
what the dashboard does — is gated the same way, on the mean of those
|
|
136
|
+
grades, so one soft dimension among strong ones still passes while an
|
|
137
|
+
answer the judge marked down cannot be carried by its mechanics. A
|
|
138
|
+
scenario the judge could not grade at all is unchanged, still falling
|
|
139
|
+
back to the rule scores. `score` and `avg_score` still mean the aggregate
|
|
140
|
+
they always did, and each model's
|
|
141
|
+
summary gains `avg_task_completion` so the judge's grade reads separately
|
|
142
|
+
from it. **This can turn a suite that passed on 1.4.0 red; see the note
|
|
143
|
+
on upgrading below.** (#414)
|
|
144
|
+
|
|
145
|
+
- **A judge's score is read as a JSON number.** The score was pulled out of
|
|
146
|
+
the judge's reply by regular expression, matching the first run of digits
|
|
147
|
+
after `"score":`. It read `{"score": 9e-2}` — 0.09 — as 9, clamped to a
|
|
148
|
+
perfect 1.0; it read the string `{"score": "0.9"}` and the truncated
|
|
149
|
+
`{"score": 0.9oops}` as a confident 0.9 rather than as unusable. The
|
|
150
|
+
score now comes from the parsed JSON object and has to be a finite
|
|
151
|
+
number, so exponent notation is read as written and a string, a boolean,
|
|
152
|
+
`null`, `NaN` or `1e999` is unscorable — which the runner already knows
|
|
153
|
+
how to handle. Fenced ```` ```json ```` replies still parse. The
|
|
154
|
+
dashboard's generation-sampling evaluations score through the engine's own
|
|
155
|
+
judge rather than the framework's, and read a score by the same rule now,
|
|
156
|
+
so the two halves of the dashboard no longer disagree about the same
|
|
157
|
+
reply. (#414)
|
|
158
|
+
|
|
159
|
+
- **A judge that answers with the wrong types cannot put junk in the fix
|
|
160
|
+
list.** `suggested_tool` and `instruction_change` were coerced rather
|
|
161
|
+
than checked, so a reply of `"suggested_tool": {"name": true}` added a
|
|
162
|
+
tool literally named `true` to the report's suggested tools, and
|
|
163
|
+
`"instruction_change": ["invalid"]` became a fix card asking someone to
|
|
164
|
+
add `["invalid"]` to the agent's instructions. Both fields must now be
|
|
165
|
+
nonempty strings and are dropped when they are not, so a malformed reply
|
|
166
|
+
loses only the malformed part: the judge's recommendation still reaches
|
|
167
|
+
the result, the report and every rendering of it. (#414)
|
|
168
|
+
|
|
169
|
+
- **A grouped suite pasted into the dashboard keeps its keys and
|
|
170
|
+
expectations.** Only a pasted list or JSON was recognised, so a YAML suite
|
|
171
|
+
went to the line parser and was read as prose: a document describing three
|
|
172
|
+
scenarios became eighteen, with prompts like `tools: [lookup_order]` and
|
|
173
|
+
`production_only: true`, groups named `expect`, generated keys in place of
|
|
174
|
+
the document's own, and every expectation dropped — a suite that looked
|
|
175
|
+
imported and scored nothing real. Such a document is now parsed as the
|
|
176
|
+
suite it is, and one that is not valid, or a selection that matches no
|
|
177
|
+
scenarios, returns an import error (`ScenarioParser::ParseError`, HTTP
|
|
178
|
+
422) instead of a suite of nonsense or a sampling evaluation nobody asked
|
|
179
|
+
for. (#414)
|
|
180
|
+
|
|
181
|
+
- **`actionagent`: run and result metadata survive persistence.** A run
|
|
182
|
+
rebuilt from the database was rebuilt without it: `Report#metadata` came
|
|
183
|
+
back holding only the four keys the engine writes itself, and each
|
|
184
|
+
result's replay metadata was gone entirely, so a host's own run and result
|
|
185
|
+
IDs, response trace IDs and judge trace IDs did not survive the round trip
|
|
186
|
+
and its reports could not be joined to its telemetry. Run metadata is now
|
|
187
|
+
kept in `scores["_metadata"]` and per-result metadata in
|
|
188
|
+
`diagnosis["_replay_metadata"]`, restored by `EvaluationRun#to_report` and
|
|
189
|
+
served as `metadata` on result JSON. Both are reserved storage keys that
|
|
190
|
+
the public diagnosis excludes, so nothing migrates and `diagnosis` still
|
|
191
|
+
means what it did. (#414)
|
|
192
|
+
|
|
193
|
+
- **`actionagent`: refreshing a catalog does not rewrite what an earlier run
|
|
194
|
+
asked.** A saved run rendered its scenarios from the catalog rows as they
|
|
195
|
+
are now, so rewording a question, retagging its group or changing its
|
|
196
|
+
expectations silently rewrote history — last month's report showed this
|
|
197
|
+
month's prompt above last month's answers, and judged them against
|
|
198
|
+
expectations that were not in force when they were given. Each result now
|
|
199
|
+
records the scenario it was actually evaluated against in
|
|
200
|
+
`diagnosis["_scenario_snapshot"]`, and the report, the API and the
|
|
201
|
+
scenario matrix read that snapshot, in the order the run itself used, with
|
|
202
|
+
the dashboard noting on a scenario whose catalog entry has since changed
|
|
203
|
+
that re-running uses the current one. A run records its judge the same
|
|
204
|
+
way, in `scores["_judge_label"]`: `Report#to_h` and `#to_markdown` now
|
|
205
|
+
name the judge a rebuilt run was given instead of reporting "No judge" for
|
|
206
|
+
every run reconstructed from the database. Results saved before this
|
|
207
|
+
release carry no snapshot and still render from the current catalog, and a
|
|
208
|
+
link to a saved report (`?evaluation=:id&run=:run_id`) now opens its
|
|
209
|
+
evaluation even when it is no longer on the first page of the index.
|
|
210
|
+
(#414)
|
|
211
|
+
|
|
212
|
+
- **`actionagent`: an observed agent cannot be made executable.** An agent
|
|
213
|
+
discovered from telemetry has no configuration to run, and `execute` and
|
|
214
|
+
`test` refused one — but `update` and `restore` did not, so an observed
|
|
215
|
+
record could be flipped to `active`, given instructions and then run; and
|
|
216
|
+
a run queued against an agent that became observed afterwards still
|
|
217
|
+
reached a provider when its job came up. The refusal now covers `update`
|
|
218
|
+
and `restore` as well, and it is enforced under the API rather than only
|
|
219
|
+
in front of it: `Agent#execute`, `#test_execute` and
|
|
220
|
+
`AgentExecutionService#call` raise
|
|
221
|
+
`ActionAgent::Agent::ObservedAgentError`, so that queued job fails its run
|
|
222
|
+
without a provider call or a trace. Duplicating the agent still gives you
|
|
223
|
+
an executable copy, and an evaluation whose host explicitly resolves an
|
|
224
|
+
adapter for it remains the one path that replays an observed agent's
|
|
225
|
+
scenarios. (#414)
|
|
226
|
+
|
|
227
|
+
- **The OpenAI Responses API keeps images and documents on a message with a
|
|
228
|
+
role.** `{ role: "user", text: "…", image: "…" }` — the shorthand the Chat
|
|
229
|
+
API and Anthropic transforms accept, and the only provider-neutral way to
|
|
230
|
+
send history followed by a multimodal turn — lost its `image:` or
|
|
231
|
+
`document:` on the provider the framework defaults to, because the
|
|
232
|
+
Responses transform kept only `content` from a role-bearing hash. It now
|
|
233
|
+
builds `input_text` / `input_image` / `input_file` parts for it, and a
|
|
234
|
+
media-only `{ role: "user", image: "…" }` becomes a message with one part.
|
|
235
|
+
The shorthand keys always come off the message, so a hash that carries
|
|
236
|
+
`content` *and* `image:` no longer sends `image` as an unknown parameter,
|
|
237
|
+
and a blank `image:`/`document:` contributes no part rather than an empty
|
|
238
|
+
one. A nil `document:` alongside a role was the unknown-parameter case; the
|
|
239
|
+
crash needed the role-less `{ document: nil }` inside a content array, which
|
|
240
|
+
called `start_with?` on nil.
|
|
241
|
+
|
|
242
|
+
- **`actionagent`: a dashboard run's trace is attributed to the agent that
|
|
243
|
+
ran it.** Every locally stored run used to register an "observed" twin of
|
|
244
|
+
its own agent, because a run's class and action match no authored record.
|
|
245
|
+
The service that ran the agent now names it when it records the trace.
|
|
246
|
+
It is named by that caller and never read from the payload: resource
|
|
247
|
+
attributes are whatever the reporter sent, and single-tenant ingest is
|
|
248
|
+
unauthenticated unless `ActionAgent.ingest_api_key` is set, so an id taken
|
|
249
|
+
from there would let any reporter bind its traces to any authored agent by
|
|
250
|
+
guessing a primary key. A host that swaps in its own `trace_model` should
|
|
251
|
+
add the `agent:` keyword to its `create_from_payload`; without it the
|
|
252
|
+
dashboard logs the error and records no trace for its own runs. (#405)
|
|
253
|
+
|
|
254
|
+
- **`actionagent`: an observed agent's history cannot be authored.** The
|
|
255
|
+
runner's conversation workbench writes an agent's history without running
|
|
256
|
+
it, and those two endpoints — starting a conversation, and seeding, editing
|
|
257
|
+
or deleting a turn — did not answer to the read-only rule execution does.
|
|
258
|
+
A turn typed into a telemetry mirror would be a fabrication attributed to
|
|
259
|
+
an agent whose whole point is that it only reports what really happened.
|
|
260
|
+
Both refuse an observed agent now, with the same message and status
|
|
261
|
+
`execute` gives. Reading that history is unchanged. (#405)
|
|
262
|
+
|
|
263
|
+
### Note on upgrading from 1.4.0
|
|
264
|
+
|
|
265
|
+
A scenario suite that passed on 1.4.0 can fail on this release with nothing
|
|
266
|
+
about your agent, your models or your suite having changed. Nothing has
|
|
267
|
+
regressed: the numbers those runs passed on were wrong, and this release
|
|
268
|
+
stops averaging them away.
|
|
269
|
+
|
|
270
|
+
A scenario's score was the mean of every criterion scored for it, and the
|
|
271
|
+
judge's `task_completion` grade — its answer to "did this actually do what
|
|
272
|
+
was asked" — was one term in that mean, alongside the rule checks. An answer
|
|
273
|
+
that called the expected tool, called it successfully, and contained the
|
|
274
|
+
expected string scored 1.0, 1.0 and 0.2 for a mean of 0.73, and passed at
|
|
275
|
+
the default threshold of 0.7: the mechanics carried the answer. That is the
|
|
276
|
+
wrong answer to the question an evaluation exists to ask. The agent called
|
|
277
|
+
`lookup_order`, said "ABC-123", and still never told the customer where the
|
|
278
|
+
order was — and the suite went green.
|
|
279
|
+
|
|
280
|
+
From this release `task_completion` has to clear `threshold` on its own.
|
|
281
|
+
Expect the first run after upgrading to show fewer passes than the run
|
|
282
|
+
before it, concentrated in the scenarios whose answers were thin, evasive or
|
|
283
|
+
wrong while their mechanics were right. Each of those now carries the
|
|
284
|
+
`low_quality` fault with a summary naming the grade that failed — "Task
|
|
285
|
+
completion scored 0.2 against a pass threshold of 0.7" — and the judge's
|
|
286
|
+
recommendation for it, and each model's summary reports
|
|
287
|
+
`avg_task_completion` beside `avg_score`, so a drop in pass rate can be read
|
|
288
|
+
against the grade that caused it. Nothing else about scoring moved: a
|
|
289
|
+
scenario the judge could not grade still falls back to its rule scores
|
|
290
|
+
unless you opt into `require_judge_scores: true`, and a suite meant to be
|
|
291
|
+
scored on mechanics alone can run without a judge or at a lower `threshold`.
|
|
292
|
+
Read that first run as a new baseline rather than a regression — it is
|
|
293
|
+
measuring something the runs before it were not.
|
|
294
|
+
|
|
10
295
|
## [1.4.0] - 2026-09-09
|
|
11
296
|
|
|
12
297
|
Releases `activeagent` 1.4.0 and `actionagent` 1.3.0 from one tag.
|
|
@@ -22,7 +22,7 @@ module ActiveAgent
|
|
|
22
22
|
class Diagnosis
|
|
23
23
|
FAULTS = %w[
|
|
24
24
|
run_error tool_error missing_capability expected_tool_not_called
|
|
25
|
-
forbidden_content missing_content low_quality
|
|
25
|
+
forbidden_content missing_content low_quality judge_unavailable
|
|
26
26
|
].freeze
|
|
27
27
|
|
|
28
28
|
# Phrasings an agent uses when nothing in its toolset covers the task.
|
|
@@ -56,11 +56,15 @@ module ActiveAgent
|
|
|
56
56
|
# @param available_tools [Array<String>] tool names the agent could call
|
|
57
57
|
# @param threshold [Float] the pass threshold for `score`
|
|
58
58
|
# @param agent_name [String] how the recommendations refer to the agent
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
# @param judge_keys [Array] keys in `scores` a judge graded the answer on
|
|
60
|
+
# (llm_judge criteria); `task_completion` always counts as one
|
|
61
|
+
def self.call(scenario:, replay:, scores:, score:, available_tools:, threshold: PASS_THRESHOLD, agent_name: "The agent",
|
|
62
|
+
judge_keys: [])
|
|
63
|
+
new(scenario:, replay:, scores:, score:, available_tools:, threshold:, agent_name:, judge_keys:).call
|
|
61
64
|
end
|
|
62
65
|
|
|
63
|
-
def initialize(scenario:, replay:, scores:, score:, available_tools:, threshold: PASS_THRESHOLD, agent_name: "The agent"
|
|
66
|
+
def initialize(scenario:, replay:, scores:, score:, available_tools:, threshold: PASS_THRESHOLD, agent_name: "The agent",
|
|
67
|
+
judge_keys: [])
|
|
64
68
|
@scenario = scenario
|
|
65
69
|
@replay = replay
|
|
66
70
|
@scores = scores || {}
|
|
@@ -68,6 +72,7 @@ module ActiveAgent
|
|
|
68
72
|
@available_tools = Array(available_tools).map(&:to_s)
|
|
69
73
|
@threshold = threshold
|
|
70
74
|
@agent_name = agent_name
|
|
75
|
+
@judge_keys = Array(judge_keys) | [ "task_completion" ]
|
|
71
76
|
end
|
|
72
77
|
|
|
73
78
|
def call
|
|
@@ -212,11 +217,22 @@ module ActiveAgent
|
|
|
212
217
|
"missing" => missing)
|
|
213
218
|
end
|
|
214
219
|
|
|
220
|
+
# A judge grade — the implicit task_completion score, or the llm_judge
|
|
221
|
+
# criteria the evaluation configured — measures the answer itself, so
|
|
222
|
+
# its mean has to reach the threshold on its own. Rule checks (a tool
|
|
223
|
+
# was called, a phrase is present) cannot carry a badly graded answer.
|
|
215
224
|
def low_quality
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
225
|
+
grades = @scores.slice(*@judge_keys).compact
|
|
226
|
+
grade = grades.any? ? (grades.values.sum / grades.size).round(3) : nil
|
|
227
|
+
failed_grade = grade && grade < @threshold
|
|
228
|
+
return nil unless failed_grade || (@score && @score < @threshold)
|
|
229
|
+
|
|
230
|
+
weakest = (failed_grade ? grades : @scores.compact).min_by { |_, value| value }
|
|
231
|
+
summary = if failed_grade
|
|
232
|
+
"#{graded_label(grades)} scored #{grade.round(2)} against a pass threshold of #{@threshold}"
|
|
233
|
+
else
|
|
234
|
+
"Scored #{@score.round(2)} against a pass threshold of #{@threshold}"
|
|
235
|
+
end
|
|
220
236
|
summary += ", weakest on #{weakest.first} (#{weakest.last.round(2)})" if weakest
|
|
221
237
|
recommendation =
|
|
222
238
|
if weakest
|
|
@@ -230,6 +246,10 @@ module ActiveAgent
|
|
|
230
246
|
result("low_quality", "#{summary}.", recommendation, "scores" => @scores)
|
|
231
247
|
end
|
|
232
248
|
|
|
249
|
+
def graded_label(grades)
|
|
250
|
+
grades.keys == [ "task_completion" ] ? "Task completion" : "Judged quality"
|
|
251
|
+
end
|
|
252
|
+
|
|
233
253
|
def result(fault, summary, recommendation, evidence = {})
|
|
234
254
|
Result.new(fault: fault, summary: summary, recommendation: recommendation, evidence: evidence.compact)
|
|
235
255
|
end
|
|
@@ -122,6 +122,9 @@ module ActiveAgent
|
|
|
122
122
|
parsed = parsed&.slice("recommendation", "suggested_tool", "instruction_change")&.compact
|
|
123
123
|
return nil if parsed.blank?
|
|
124
124
|
|
|
125
|
+
%w[recommendation instruction_change].each do |key|
|
|
126
|
+
parsed.delete(key) unless parsed[key].is_a?(String) && parsed[key].present?
|
|
127
|
+
end
|
|
125
128
|
parsed["suggested_tool"] = suggested_tool(parsed["suggested_tool"]) if parsed.key?("suggested_tool")
|
|
126
129
|
parsed.compact.presence
|
|
127
130
|
end
|
|
@@ -164,7 +167,9 @@ module ActiveAgent
|
|
|
164
167
|
def suggested_tool(tool)
|
|
165
168
|
case tool
|
|
166
169
|
when Hash
|
|
167
|
-
|
|
170
|
+
if tool["name"].is_a?(String) && tool["name"].present?
|
|
171
|
+
{ "name" => tool["name"], "description" => tool["description"].is_a?(String) ? tool["description"] : "" }
|
|
172
|
+
end
|
|
168
173
|
when String
|
|
169
174
|
{ "name" => tool, "description" => "" } if tool.present?
|
|
170
175
|
end
|
|
@@ -187,8 +192,10 @@ module ActiveAgent
|
|
|
187
192
|
end
|
|
188
193
|
|
|
189
194
|
def parse_score(content)
|
|
190
|
-
|
|
191
|
-
|
|
195
|
+
value = parse_object(content)&.dig("score")
|
|
196
|
+
return nil unless value.is_a?(Numeric) && value.finite?
|
|
197
|
+
|
|
198
|
+
value.to_f.clamp(0.0, 1.0)
|
|
192
199
|
end
|
|
193
200
|
|
|
194
201
|
def parse_object(content)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module ActiveAgent
|
|
9
|
+
module Evals
|
|
10
|
+
# Publishes a completed report without replaying the agent. The caller must
|
|
11
|
+
# retain run_id when retrying: compatible collectors treat that identity as
|
|
12
|
+
# immutable within the authenticated account. Delivery is blocking and does
|
|
13
|
+
# not follow redirects with the account's bearer credential.
|
|
14
|
+
class Publisher
|
|
15
|
+
DEFAULT_ENDPOINT = "https://api.activeagents.ai/v1/evaluations"
|
|
16
|
+
MAX_BYTES = 2 * 1024 * 1024
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
|
|
19
|
+
def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, timeout: 10, open_timeout: 10)
|
|
20
|
+
@uri = URI.parse(endpoint.to_s)
|
|
21
|
+
unless @uri.is_a?(URI::HTTP) && @uri.host && !@uri.userinfo && !@uri.query && !@uri.fragment
|
|
22
|
+
raise ArgumentError, "Evaluation endpoint must be an HTTP(S) URL without credentials, query or fragment"
|
|
23
|
+
end
|
|
24
|
+
unless @uri.scheme == "https" || %w[localhost 127.0.0.1 ::1].include?(@uri.hostname)
|
|
25
|
+
raise ArgumentError, "Evaluation endpoint requires HTTPS except on loopback hosts"
|
|
26
|
+
end
|
|
27
|
+
raise ArgumentError, "Evaluation API key is required" if api_key.to_s.strip.empty?
|
|
28
|
+
|
|
29
|
+
@api_key = api_key.to_s
|
|
30
|
+
@timeout = Float(timeout)
|
|
31
|
+
@open_timeout = Float(open_timeout)
|
|
32
|
+
unless [ @timeout, @open_timeout ].all? { |value| value.finite? && value.positive? }
|
|
33
|
+
raise ArgumentError, "Evaluation delivery timeouts must be positive and finite"
|
|
34
|
+
end
|
|
35
|
+
rescue URI::InvalidURIError
|
|
36
|
+
raise ArgumentError, "Evaluation endpoint is not a valid URL"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# report may be a Report or its saved JSON hash. Full prompts, answers and
|
|
40
|
+
# tool results are included; applications should make publication opt-in.
|
|
41
|
+
def call(report:, run_id:, source:, agent_name:, suite:)
|
|
42
|
+
identities = { "run_id" => run_id, "source" => source, "agent_name" => agent_name, "suite" => suite }
|
|
43
|
+
identities.each do |key, value|
|
|
44
|
+
raise ArgumentError, "#{key} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
|
|
45
|
+
end
|
|
46
|
+
body = JSON.generate(identities.merge("version" => 1, "report" => report.to_h))
|
|
47
|
+
raise Error, "Evaluation report exceeds the 2 MiB delivery limit; publish a smaller selection" if body.bytesize > MAX_BYTES
|
|
48
|
+
|
|
49
|
+
http = Net::HTTP.new(@uri.hostname, @uri.port)
|
|
50
|
+
http.use_ssl = @uri.scheme == "https"
|
|
51
|
+
http.open_timeout = @open_timeout
|
|
52
|
+
http.read_timeout = @timeout
|
|
53
|
+
http.write_timeout = @timeout
|
|
54
|
+
request = Net::HTTP::Post.new(@uri.request_uri)
|
|
55
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
56
|
+
request["Content-Type"] = "application/json"
|
|
57
|
+
request["Accept"] = "application/json"
|
|
58
|
+
request.body = body
|
|
59
|
+
response = http.request(request)
|
|
60
|
+
unless %w[200 201].include?(response.code)
|
|
61
|
+
raise Error, "Evaluation delivery rejected (HTTP #{response.code}); retain the report and run_id for retry"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
receipt = JSON.parse(response.body)
|
|
65
|
+
unless receipt.is_a?(Hash) && receipt["run_id"] == run_id && receipt["status"] == "complete" && receipt["id"] && receipt["evaluation_id"]
|
|
66
|
+
raise Error, "Evaluation collector returned an invalid completion receipt; retain the report and run_id for retry"
|
|
67
|
+
end
|
|
68
|
+
receipt
|
|
69
|
+
rescue JSON::ParserError
|
|
70
|
+
raise Error, "Evaluation collector returned invalid JSON; retain the report and run_id for retry"
|
|
71
|
+
rescue IOError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => e
|
|
72
|
+
raise Error, "Evaluation delivery failed (#{e.class}); retain the report and run_id for retry"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -54,6 +54,7 @@ module ActiveAgent
|
|
|
54
54
|
@summary_by_model ||= @models.to_h do |spec|
|
|
55
55
|
cohort = @results.select { |result| result.label == spec.label }
|
|
56
56
|
scored = cohort.filter_map(&:score)
|
|
57
|
+
task_scores = cohort.filter_map { |result| result.scores["task_completion"] }
|
|
57
58
|
durations = cohort.filter_map { |result| result.replay.duration_ms }
|
|
58
59
|
costs = cohort.filter_map { |result| result.replay.cost }
|
|
59
60
|
|
|
@@ -65,6 +66,7 @@ module ActiveAgent
|
|
|
65
66
|
"errored" => cohort.count(&:errored?),
|
|
66
67
|
"pass_rate" => cohort.any? ? (cohort.count(&:passed?) * 100.0 / cohort.size).round(1) : 0.0,
|
|
67
68
|
"avg_score" => scored.any? ? (scored.sum / scored.size).round(3) : nil,
|
|
69
|
+
"avg_task_completion" => task_scores.any? ? (task_scores.sum / task_scores.size).round(3) : nil,
|
|
68
70
|
"avg_duration_ms" => durations.any? ? (durations.sum.to_f / durations.size).round : nil,
|
|
69
71
|
"input_tokens" => cohort.sum { |result| result.replay.input_tokens.to_i },
|
|
70
72
|
"output_tokens" => cohort.sum { |result| result.replay.output_tokens.to_i },
|
|
@@ -159,7 +161,7 @@ module ActiveAgent
|
|
|
159
161
|
"criteria" => criterion_scores,
|
|
160
162
|
"recommendations" => recommendations,
|
|
161
163
|
"verdict" => verdict,
|
|
162
|
-
"judge" => @judge&.label,
|
|
164
|
+
"judge" => @judge_label || @judge&.label,
|
|
163
165
|
"metadata" => @metadata.presence,
|
|
164
166
|
"results" => @results.map(&:to_h)
|
|
165
167
|
}.compact
|
|
@@ -172,7 +174,8 @@ module ActiveAgent
|
|
|
172
174
|
def to_markdown
|
|
173
175
|
scenario_count = @results.map { |result| result.scenario.key }.uniq.size
|
|
174
176
|
lines = [ "# Evaluation — #{scenario_count} scenario#{'s' unless scenario_count == 1} × #{@models.size} model#{'s' unless @models.size == 1}", "" ]
|
|
175
|
-
|
|
177
|
+
label = @judge_label || @judge&.label
|
|
178
|
+
lines << (label ? "Judged by `#{label}`." : "No judge; scored on rules and expectations alone.")
|
|
176
179
|
lines << ""
|
|
177
180
|
lines.concat(summary_table)
|
|
178
181
|
lines << ""
|
|
@@ -308,8 +308,8 @@ module ActiveAgent
|
|
|
308
308
|
%(<div class="tools"><span class="micro sm">#{h(item['tools_label'])}</span><div class="list">#{chips.join}</div></div>)
|
|
309
309
|
end
|
|
310
310
|
|
|
311
|
-
# "available · not enabled for
|
|
312
|
-
#
|
|
311
|
+
# "available · not enabled for Assistant", "unknown · not enabled for
|
|
312
|
+
# Assistant" — every status but "enabled" leads with the status word, the
|
|
313
313
|
# way the dashboard's fix list reads it.
|
|
314
314
|
def html_fix_server(server)
|
|
315
315
|
badge =
|
|
@@ -8,8 +8,10 @@ module ActiveAgent
|
|
|
8
8
|
# The one thing the runner does not know is how to talk to your agent;
|
|
9
9
|
# `replay` is a callable `(scenario, model_spec) → Replay` (a Hash with the
|
|
10
10
|
# same keys is accepted, and an exception becomes an errored Replay). A
|
|
11
|
-
# scenario passes when its replay completed, met its expectations, and
|
|
12
|
-
# mean score
|
|
11
|
+
# scenario passes when its replay completed, met its expectations, and both
|
|
12
|
+
# its mean score and the mean of its judge grades (task completion, or the
|
|
13
|
+
# configured llm_judge criteria) reached `threshold`;
|
|
14
|
+
# anything else carries exactly one fault
|
|
13
15
|
# and a recommendation from Diagnosis, refined by the `judge` for the
|
|
14
16
|
# faults in `refine_faults` (up to `judge_limit` calls per run).
|
|
15
17
|
#
|
|
@@ -42,9 +44,17 @@ module ActiveAgent
|
|
|
42
44
|
# @param instructions [String, nil] the agent's instructions, for the judge
|
|
43
45
|
# @param agent_name [String] how recommendations refer to the agent
|
|
44
46
|
# @param on_result [#call, nil] called with each Result as it lands
|
|
47
|
+
# @param around_evaluation [#call, nil] called with (scenario, spec) and a
|
|
48
|
+
# block that returns the Result. Establishes context for replay, scoring
|
|
49
|
+
# and recommendations; must return the block's result. Wrapper errors
|
|
50
|
+
# propagate to the caller. Applies to #call, not direct #evaluate calls.
|
|
51
|
+
# @param require_judge_scores [Boolean] fail an otherwise passing result
|
|
52
|
+
# when a requested task/LLM grade is unavailable, rather than falling
|
|
53
|
+
# back to rule scores. Does not require a judge for rules-only runs.
|
|
45
54
|
def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
|
|
46
55
|
instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
|
|
47
|
-
refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
|
|
56
|
+
refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
|
|
57
|
+
around_evaluation: nil, require_judge_scores: false, metadata: {})
|
|
48
58
|
@scenarios = scenarios
|
|
49
59
|
@models = models
|
|
50
60
|
@replay = replay
|
|
@@ -58,6 +68,8 @@ module ActiveAgent
|
|
|
58
68
|
@refine_faults = refine_faults
|
|
59
69
|
@judge_limit = judge_limit
|
|
60
70
|
@on_result = on_result
|
|
71
|
+
@around_evaluation = around_evaluation
|
|
72
|
+
@require_judge_scores = require_judge_scores
|
|
61
73
|
@metadata = metadata
|
|
62
74
|
@judge_calls = 0
|
|
63
75
|
@scorer = Scorer.new(criteria: criteria, judge: judge)
|
|
@@ -66,7 +78,7 @@ module ActiveAgent
|
|
|
66
78
|
def call
|
|
67
79
|
results = @scenarios.flat_map do |scenario|
|
|
68
80
|
@models.map do |spec|
|
|
69
|
-
|
|
81
|
+
evaluate_with_context(scenario, spec).tap { |result| @on_result&.call(result) }
|
|
70
82
|
end
|
|
71
83
|
end
|
|
72
84
|
|
|
@@ -85,7 +97,9 @@ module ActiveAgent
|
|
|
85
97
|
score = Scorer.mean(scores)
|
|
86
98
|
|
|
87
99
|
diagnosis = Diagnosis.call(scenario: scenario, replay: replay, scores: scores, score: score,
|
|
88
|
-
available_tools: @available_tools.keys, threshold: @threshold, agent_name: @agent_name
|
|
100
|
+
available_tools: @available_tools.keys, threshold: @threshold, agent_name: @agent_name,
|
|
101
|
+
judge_keys: llm_judge_keys)
|
|
102
|
+
diagnosis ||= unavailable_judge_diagnosis(scores)
|
|
89
103
|
diagnosis_hash = diagnosis&.to_h
|
|
90
104
|
refine!(diagnosis_hash, scenario, replay, diagnosis) if diagnosis_hash
|
|
91
105
|
|
|
@@ -102,10 +116,51 @@ module ActiveAgent
|
|
|
102
116
|
|
|
103
117
|
private
|
|
104
118
|
|
|
119
|
+
def evaluate_with_context(scenario, spec)
|
|
120
|
+
return evaluate(scenario, spec) unless @around_evaluation
|
|
121
|
+
|
|
122
|
+
result = @around_evaluation.call(scenario, spec) { evaluate(scenario, spec) }
|
|
123
|
+
# A wrapper written the natural way — do something, yield, do something
|
|
124
|
+
# after — returns that last value rather than the Result. Left alone it
|
|
125
|
+
# reaches on_result and the Report, and fails somewhere far from the
|
|
126
|
+
# wrapper that caused it. Name the wrapper here instead.
|
|
127
|
+
unless result.is_a?(Result)
|
|
128
|
+
raise ArgumentError, "around_evaluation must return the Result its block yields, got #{result.class}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
result
|
|
132
|
+
end
|
|
133
|
+
|
|
105
134
|
def judge_task?
|
|
106
135
|
@judge && @judge_task && @criteria.none? { |criterion| criterion.to_h.stringify_keys["type"] == "llm_judge" }
|
|
107
136
|
end
|
|
108
137
|
|
|
138
|
+
# The keys in `scores` a judge graded, so a low grade is not averaged
|
|
139
|
+
# away against rule checks.
|
|
140
|
+
def llm_judge_keys
|
|
141
|
+
@llm_judge_keys ||= @criteria.filter_map do |criterion|
|
|
142
|
+
value = criterion.to_h.stringify_keys
|
|
143
|
+
value["key"] if value["type"] == "llm_judge"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def unavailable_judge_diagnosis(scores)
|
|
148
|
+
return unless @require_judge_scores
|
|
149
|
+
|
|
150
|
+
keys = llm_judge_keys.dup
|
|
151
|
+
keys << "task_completion" if judge_task?
|
|
152
|
+
missing = keys.select { |key| scores[key].nil? }
|
|
153
|
+
return if missing.empty?
|
|
154
|
+
|
|
155
|
+
Diagnosis::Result.new(
|
|
156
|
+
fault: "judge_unavailable",
|
|
157
|
+
summary: "The evaluation judge did not return a usable score for #{missing.join(', ')}.",
|
|
158
|
+
recommendation: "Check the judge's credentials, model availability and JSON response, then re-run this evaluation. " \
|
|
159
|
+
"The available rule scores do not establish answer quality.",
|
|
160
|
+
evidence: { "unscored_criteria" => missing }
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
109
164
|
# Whatever the callable raises becomes an errored Replay, so one model
|
|
110
165
|
# rejecting a parameter fails its scenario rather than the whole run.
|
|
111
166
|
# A return value that is neither a Replay nor a Hash is the caller's
|
|
@@ -15,11 +15,15 @@ module ActiveAgent
|
|
|
15
15
|
# options on a line
|
|
16
16
|
# - a JSON array of strings, or of objects with `prompt` (or `message`),
|
|
17
17
|
# `group`, `key`, `notes`, `tools`, `contains`, `not_contains`
|
|
18
|
+
# - a grouped Suite document in YAML or JSON, retaining its expectations,
|
|
19
|
+
# group names, stable keys, notes and production-only flags
|
|
18
20
|
#
|
|
19
21
|
# Every scenario gets a key unique within the paste, derived from its group
|
|
20
22
|
# and position ("blame_3"), unless the line names one. The result is an
|
|
21
23
|
# array of string-keyed hashes; `Scenario.from_hash` builds the structs.
|
|
22
24
|
class ScenarioParser
|
|
25
|
+
class ParseError < ArgumentError; end
|
|
26
|
+
|
|
23
27
|
LIST_MARKER = /\A\s*(?:[-*•]|\d+[.)])\s+/
|
|
24
28
|
HEADING = /\A\s*#+\s+(.+?)\s*\z/
|
|
25
29
|
BOLD_HEADING = /\A\s*\*\*(.+?)\*\*:?\s*(?:—.*)?\z/
|
|
@@ -28,13 +32,13 @@ module ActiveAgent
|
|
|
28
32
|
BACKTICK_PROMPT = /\A`([^`]+)`/
|
|
29
33
|
OPTION_KEYS = %w[tools contains not_contains key group notes].freeze
|
|
30
34
|
|
|
31
|
-
def self.parse(text)
|
|
32
|
-
new(text).parse
|
|
35
|
+
def self.parse(text, include_production_only: true)
|
|
36
|
+
new(text).parse(include_production_only: include_production_only)
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
# Parses and builds Scenario structs in one step.
|
|
36
|
-
def self.scenarios(text)
|
|
37
|
-
parse(text).map { |attrs| Scenario.from_hash(attrs) }
|
|
40
|
+
def self.scenarios(text, include_production_only: true)
|
|
41
|
+
parse(text, include_production_only: include_production_only).map { |attrs| Scenario.from_hash(attrs) }
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
def initialize(text)
|
|
@@ -42,12 +46,19 @@ module ActiveAgent
|
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
# @return [Array<Hash>] scenario attributes with string keys
|
|
45
|
-
def parse
|
|
49
|
+
def parse(include_production_only: true)
|
|
46
50
|
stripped = @text.strip
|
|
47
51
|
return [] if stripped.empty?
|
|
48
52
|
|
|
49
|
-
scenarios = json?(stripped)
|
|
50
|
-
|
|
53
|
+
scenarios = if json?(stripped)
|
|
54
|
+
parse_json(stripped)
|
|
55
|
+
elsif stripped.match?(/^(?:suite|groups):(?:\s|$)/)
|
|
56
|
+
parse_suite_yaml(stripped)
|
|
57
|
+
else
|
|
58
|
+
parse_lines(stripped)
|
|
59
|
+
end
|
|
60
|
+
assigned = assign_keys(scenarios)
|
|
61
|
+
include_production_only ? assigned : assigned.reject { |entry| entry["production_only"] }
|
|
51
62
|
end
|
|
52
63
|
|
|
53
64
|
private
|
|
@@ -58,6 +69,8 @@ module ActiveAgent
|
|
|
58
69
|
|
|
59
70
|
def parse_json(text)
|
|
60
71
|
parsed = JSON.parse(text)
|
|
72
|
+
return parse_suite(parsed) if parsed.is_a?(Hash) && parsed.key?("groups")
|
|
73
|
+
|
|
61
74
|
parsed = parsed["scenarios"] if parsed.is_a?(Hash) && parsed.key?("scenarios")
|
|
62
75
|
parsed = [ parsed ] if parsed.is_a?(Hash)
|
|
63
76
|
|
|
@@ -71,6 +84,39 @@ module ActiveAgent
|
|
|
71
84
|
parse_lines(text)
|
|
72
85
|
end
|
|
73
86
|
|
|
87
|
+
def parse_suite_yaml(text)
|
|
88
|
+
document = YAML.safe_load(text, aliases: true)
|
|
89
|
+
raise ParseError, "evaluation suite must contain a groups array" unless document.is_a?(Hash) && document["groups"].is_a?(Array)
|
|
90
|
+
|
|
91
|
+
parse_suite(document)
|
|
92
|
+
rescue Psych::Exception => e
|
|
93
|
+
raise ParseError, "invalid evaluation suite YAML: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_suite(document)
|
|
97
|
+
raise ParseError, "evaluation suite must contain a groups array" unless document["groups"].is_a?(Array)
|
|
98
|
+
|
|
99
|
+
document["groups"].each do |group|
|
|
100
|
+
unless group.is_a?(Hash) && (group["scenarios"].nil? || group["scenarios"].is_a?(Array))
|
|
101
|
+
raise ParseError, "each evaluation group must contain a scenarios array"
|
|
102
|
+
end
|
|
103
|
+
Array(group["scenarios"]).each do |entry|
|
|
104
|
+
unless entry.is_a?(Hash) && entry["prompt"].is_a?(String) && entry["prompt"].present?
|
|
105
|
+
raise ParseError, "each evaluation scenario must contain a prompt"
|
|
106
|
+
end
|
|
107
|
+
expectations = entry["expectations"] || entry["expect"]
|
|
108
|
+
if expectations && !expectations.is_a?(Hash)
|
|
109
|
+
raise ParseError, "scenario expectations must be an object"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
Suite.new([ document ]).all_scenarios.map do |item|
|
|
115
|
+
scenario(prompt: item.prompt, group: item.group, group_name: item.group_name, key: item.key,
|
|
116
|
+
notes: item.notes, expectations: item.expectations, production_only: item.production_only?)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
74
120
|
def scenario_from_hash(entry)
|
|
75
121
|
entry = entry.stringify_keys
|
|
76
122
|
prompt = entry["prompt"] || entry["message"] || entry["input"] || entry["question"]
|
|
@@ -84,9 +130,11 @@ module ActiveAgent
|
|
|
84
130
|
scenario(
|
|
85
131
|
prompt: prompt,
|
|
86
132
|
group: entry["group"],
|
|
133
|
+
group_name: entry["group_name"],
|
|
87
134
|
key: entry["key"],
|
|
88
135
|
notes: entry["notes"],
|
|
89
|
-
expectations: expectations
|
|
136
|
+
expectations: expectations,
|
|
137
|
+
production_only: entry["production_only"] == true
|
|
90
138
|
)
|
|
91
139
|
end
|
|
92
140
|
|
|
@@ -172,13 +220,15 @@ module ActiveAgent
|
|
|
172
220
|
text.to_s.gsub(/\*\*|__|`/, "").strip
|
|
173
221
|
end
|
|
174
222
|
|
|
175
|
-
def scenario(prompt:, group: nil, key: nil, notes: nil, expectations: {})
|
|
223
|
+
def scenario(prompt:, group: nil, group_name: nil, key: nil, notes: nil, expectations: {}, production_only: false)
|
|
176
224
|
{
|
|
177
225
|
"prompt" => prompt.to_s.strip,
|
|
178
226
|
"group" => group.presence&.to_s&.strip,
|
|
227
|
+
"group_name" => group_name.presence&.to_s&.strip,
|
|
179
228
|
"key" => key.presence&.to_s&.strip,
|
|
180
229
|
"notes" => notes.presence,
|
|
181
|
-
"expectations" => (expectations || {}).reject { |_, value| value.blank? }
|
|
230
|
+
"expectations" => (expectations || {}).reject { |_, value| value.blank? },
|
|
231
|
+
"production_only" => production_only
|
|
182
232
|
}
|
|
183
233
|
end
|
|
184
234
|
|
data/lib/active_agent/evals.rb
CHANGED
|
@@ -59,7 +59,8 @@ module ActiveAgent
|
|
|
59
59
|
:tools_function, # Callback (Tools)
|
|
60
60
|
:usage_stack, # Usage Tracking
|
|
61
61
|
:stream_usage_index, # Usage Tracking (Streams)
|
|
62
|
-
:max_tool_turns, :tool_turns
|
|
62
|
+
:max_tool_turns, :tool_turns, # Tool-loop safety
|
|
63
|
+
:instrumentation_enabled # Per-generation privacy
|
|
63
64
|
|
|
64
65
|
# Upper bound on tool-calling round-trips within one generation. A
|
|
65
66
|
# model that keeps emitting tool calls otherwise recurses until the
|
|
@@ -117,6 +118,7 @@ module ActiveAgent
|
|
|
117
118
|
self.tools_function = kwargs.delete(:tools_function)
|
|
118
119
|
self.max_tool_turns = kwargs.delete(:max_tool_turns) || DEFAULT_MAX_TOOL_TURNS
|
|
119
120
|
self.tool_turns = 0
|
|
121
|
+
self.instrumentation_enabled = kwargs.delete(:instrumentation) != false
|
|
120
122
|
self.options = options_klass.new(kwargs.extract!(*options_klass.keys))
|
|
121
123
|
self.context = kwargs
|
|
122
124
|
self.message_stack = []
|
|
@@ -175,6 +177,8 @@ module ActiveAgent
|
|
|
175
177
|
# @yield block to instrument
|
|
176
178
|
# @return [Object] block result
|
|
177
179
|
def instrument(name, payload = {}, &block)
|
|
180
|
+
return block&.call(payload) unless instrumentation_enabled
|
|
181
|
+
|
|
178
182
|
full_payload = { provider: service_name, provider_module: tag_name, trace_id: }.merge(payload)
|
|
179
183
|
ActiveSupport::Notifications.instrument(name, full_payload, &block)
|
|
180
184
|
end
|
|
@@ -21,8 +21,10 @@ module ActiveAgent
|
|
|
21
21
|
if content_type == :text
|
|
22
22
|
self.content = value
|
|
23
23
|
else
|
|
24
|
-
#
|
|
25
|
-
#
|
|
24
|
+
# No vision here: an image/document stands in as a text
|
|
25
|
+
# marker, so a media-only turn still has content to validate
|
|
26
|
+
# and to concatenate with its neighbours when serialized.
|
|
27
|
+
self.content ||= "[#{content_type}]"
|
|
26
28
|
end
|
|
27
29
|
end
|
|
28
30
|
end
|
|
@@ -295,25 +295,43 @@ module ActiveAgent
|
|
|
295
295
|
if message.respond_to?(:serialize)
|
|
296
296
|
message.serialize
|
|
297
297
|
elsif message.is_a?(Hash)
|
|
298
|
-
# If it has a role, it's a message
|
|
298
|
+
# If it has a role, it's a message. Its :text becomes :content,
|
|
299
|
+
# and an :image / :document alongside it becomes a content
|
|
300
|
+
# part — the same `{role:, text:, image:}` shorthand the Chat
|
|
301
|
+
# API and Anthropic transforms accept, so a caller sending
|
|
302
|
+
# history plus a multimodal turn gets the same request shape
|
|
303
|
+
# from every provider.
|
|
299
304
|
if message.key?(:role)
|
|
300
305
|
normalized = message.dup
|
|
301
|
-
|
|
302
|
-
|
|
306
|
+
# The shorthand keys always come off the message: left on,
|
|
307
|
+
# they reach the request body as unknown parameters and the
|
|
308
|
+
# API rejects the whole call. A blank one contributes no
|
|
309
|
+
# part rather than an empty input_image the API would
|
|
310
|
+
# refuse (or a nil document, which has no URL to send).
|
|
311
|
+
text = normalized.delete(:text)
|
|
312
|
+
image = normalized.delete(:image)
|
|
313
|
+
document = normalized.delete(:document)
|
|
314
|
+
|
|
315
|
+
unless normalized.key?(:content)
|
|
316
|
+
parts = []
|
|
317
|
+
parts << { type: "input_text", text: text } if text.present?
|
|
318
|
+
parts << { type: "input_image", image_url: image } if image.present?
|
|
319
|
+
parts << document_part(document) if document.present?
|
|
320
|
+
|
|
321
|
+
if parts.size == 1 && parts.first[:type] == "input_text"
|
|
322
|
+
normalized[:content] = parts.first[:text]
|
|
323
|
+
elsif parts.any?
|
|
324
|
+
normalized[:content] = parts
|
|
325
|
+
end
|
|
303
326
|
end
|
|
304
327
|
return normalized
|
|
305
328
|
end
|
|
306
329
|
|
|
307
330
|
# Expand shorthand formats to full structures for content items
|
|
308
|
-
if message.
|
|
331
|
+
if message[:image].present?
|
|
309
332
|
{ type: "input_image", image_url: message[:image] }
|
|
310
|
-
elsif message.
|
|
311
|
-
|
|
312
|
-
if document_value.start_with?("data:")
|
|
313
|
-
{ type: "input_file", filename: "document.pdf", file_data: document_value }
|
|
314
|
-
else
|
|
315
|
-
{ type: "input_file", file_url: document_value }
|
|
316
|
-
end
|
|
333
|
+
elsif message[:document].present?
|
|
334
|
+
document_part(message[:document])
|
|
317
335
|
elsif message.key?(:text) && message.size == 1
|
|
318
336
|
# Single :text key without :role - treat as user message
|
|
319
337
|
{ role: "user", content: message[:text] }
|
|
@@ -336,6 +354,19 @@ module ActiveAgent
|
|
|
336
354
|
end
|
|
337
355
|
end
|
|
338
356
|
|
|
357
|
+
# An input_file part for a document given as a URL or a data URI.
|
|
358
|
+
#
|
|
359
|
+
# @param document_value [String] URL or data URI
|
|
360
|
+
# @return [Hash] input_file content part
|
|
361
|
+
def document_part(document_value)
|
|
362
|
+
document_value = document_value.to_s
|
|
363
|
+
if document_value.start_with?("data:")
|
|
364
|
+
{ type: "input_file", filename: "document.pdf", file_data: document_value }
|
|
365
|
+
else
|
|
366
|
+
{ type: "input_file", file_url: document_value }
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
339
370
|
# Cleans up serialized request for API submission
|
|
340
371
|
#
|
|
341
372
|
# Removes default values and simplifies input where possible.
|
|
@@ -39,6 +39,7 @@ module ActiveAgent
|
|
|
39
39
|
module GenerationInstrumentation
|
|
40
40
|
# Wraps process_prompt with telemetry tracing.
|
|
41
41
|
def process_prompt
|
|
42
|
+
return super if respond_to?(:prompt_options) && prompt_options.is_a?(Hash) && prompt_options[:instrumentation] == false
|
|
42
43
|
return super unless Telemetry.enabled?
|
|
43
44
|
|
|
44
45
|
# Reuse (or mint) the generation's trace id so the telemetry trace
|
|
@@ -210,6 +211,7 @@ module ActiveAgent
|
|
|
210
211
|
# don't expose tool calls.
|
|
211
212
|
def tools_function
|
|
212
213
|
base = super
|
|
214
|
+
return base if respond_to?(:prompt_options) && prompt_options.is_a?(Hash) && prompt_options[:instrumentation] == false
|
|
213
215
|
return base unless Telemetry.enabled?
|
|
214
216
|
|
|
215
217
|
agent = self
|
|
@@ -247,6 +249,7 @@ module ActiveAgent
|
|
|
247
249
|
|
|
248
250
|
# Wraps process_embed with telemetry tracing.
|
|
249
251
|
def process_embed
|
|
252
|
+
return super if respond_to?(:embed_options) && embed_options.is_a?(Hash) && embed_options[:instrumentation] == false
|
|
250
253
|
return super unless Telemetry.enabled?
|
|
251
254
|
|
|
252
255
|
Telemetry.trace("#{self.class.name}.embed", span_type: :embedding) do |span|
|
data/lib/active_agent/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activeagent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Justin Bowen
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: actionpack
|
|
@@ -436,6 +435,7 @@ files:
|
|
|
436
435
|
- lib/active_agent/evals/diagnosis.rb
|
|
437
436
|
- lib/active_agent/evals/judge.rb
|
|
438
437
|
- lib/active_agent/evals/model_spec.rb
|
|
438
|
+
- lib/active_agent/evals/publisher.rb
|
|
439
439
|
- lib/active_agent/evals/replay.rb
|
|
440
440
|
- lib/active_agent/evals/report.rb
|
|
441
441
|
- lib/active_agent/evals/report_html.rb
|
|
@@ -600,7 +600,6 @@ metadata:
|
|
|
600
600
|
documentation_uri: https://docs.activeagents.ai
|
|
601
601
|
source_code_uri: https://github.com/activeagents/activeagent
|
|
602
602
|
rubygems_mfa_required: 'true'
|
|
603
|
-
post_install_message:
|
|
604
603
|
rdoc_options: []
|
|
605
604
|
require_paths:
|
|
606
605
|
- lib
|
|
@@ -615,8 +614,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
615
614
|
- !ruby/object:Gem::Version
|
|
616
615
|
version: '0'
|
|
617
616
|
requirements: []
|
|
618
|
-
rubygems_version:
|
|
619
|
-
signing_key:
|
|
617
|
+
rubygems_version: 4.0.16
|
|
620
618
|
specification_version: 4
|
|
621
619
|
summary: Rails AI Agents Framework
|
|
622
620
|
test_files: []
|