activeagent 1.4.0 → 1.5.2
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 +364 -0
- data/lib/active_agent/evals/diagnosis.rb +28 -8
- data/lib/active_agent/evals/judge.rb +10 -3
- data/lib/active_agent/evals/model_spec.rb +15 -1
- 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/railtie.rb +10 -0
- data/lib/active_agent/schema_tools.rb +438 -0
- data/lib/active_agent/telemetry/instrumentation.rb +3 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 73e63c87b700b6dec4e3410ae0547ff1c02714fbe79b24175447fb5638a9fe3b
|
|
4
|
+
data.tar.gz: ef361dc3bb7dcab252c0fadf1fb526e689b97b21386a2d50b6ba6eb96a84100a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7ed21c4a284e17d0014ef83b09fc56741b96cbb83f219f38dc3e8ad87f511f9ed5b3d902e1b1e9c7e4db11c83bce07ed0effbb2f44d85c35d0da4738598744b3
|
|
7
|
+
data.tar.gz: c3634cfbe8e532889e2ef82b7080b30f0233a0faf1e81b5c37995949a8c7a6d5e6ebf9e3800e39457c54de3803d22b3545d4606d448867a777915cabf1a39b15
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,370 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.5.2] - 2026-09-11
|
|
11
|
+
|
|
12
|
+
Releases `activeagent` and `actionagent` 1.5.2 from one tag.
|
|
13
|
+
|
|
14
|
+
**1.5.1 was never tagged.** Its version bump reached `main`, but three PRs
|
|
15
|
+
that change `actionagent` merged alongside it, and that release deliberately
|
|
16
|
+
held `actionagent` at 1.5.0 — publishing it would have shipped the fix below
|
|
17
|
+
while leaving every dashboard change of this cycle unpublished, because
|
|
18
|
+
`release.yml` skips a version already on RubyGems. 1.5.2 supersedes it and
|
|
19
|
+
carries both gems. The 1.5.1 notes are kept below as the record of what that
|
|
20
|
+
bump contained.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Schema-derived agent tools.** `ActiveAgent::SchemaTools` turns an
|
|
25
|
+
ActiveRecord model plus a declared boundary into a bounded, enumerable tool
|
|
26
|
+
roster — `find_*`, `count_*`, `get_*` — with `filterable` and `returns`
|
|
27
|
+
allowlists. An undeclared column is rejected rather than silently dropped:
|
|
28
|
+
ignoring an unknown filter answers a broader question than was asked while
|
|
29
|
+
still looking like success. Results are capped (25 default, 100 max) with a
|
|
30
|
+
truncation marker the model can see. (#435)
|
|
31
|
+
|
|
32
|
+
- **Host tools reach the dashboard.** `ActionAgent.schema_tools` offers each
|
|
33
|
+
generated tool beside `AgentToolbox`'s built-ins: individually selectable in
|
|
34
|
+
the agent editor, dispatched by name at execution, and nameable in an
|
|
35
|
+
evaluation's `tools:` expectation. Previously a declared schema tool was
|
|
36
|
+
invisible — `definitions_for` returned nothing, the model received no
|
|
37
|
+
schemas and invented tool names in prose while the run scored 0.0 for what
|
|
38
|
+
looked like a model failure. (#435, closes #438)
|
|
39
|
+
|
|
40
|
+
- **Tools are discovered, not declared twice.** Leave `schema_tools` unset and
|
|
41
|
+
every subclass under `schema_tools_path` (`app/agent_tools`) is offered.
|
|
42
|
+
Adding a tool is adding a file. Anonymous classes are excluded from
|
|
43
|
+
discovery: a runtime-built class cannot supersede itself, so it would
|
|
44
|
+
accumulate one per reload. (#435, refs #440)
|
|
45
|
+
|
|
46
|
+
- **`scope_by_policy`** resolves a model's policy by name —
|
|
47
|
+
`Reservation` → `ReservationPolicy::Scope` — instead of hand-writing the
|
|
48
|
+
block. Opt-in, because silently scoping a class that declared none would
|
|
49
|
+
change what an existing tool returns; a missing policy raises at declaration
|
|
50
|
+
rather than quietly reading the whole table. (#435)
|
|
51
|
+
|
|
52
|
+
- **A model's agent starts with that model's tools.** `ReservationAgent` is
|
|
53
|
+
seeded from `ReservationTools` on create. A default, never a restriction:
|
|
54
|
+
any agent may enable any tool, and an explicit selection — including a
|
|
55
|
+
deliberate empty one — is never overwritten. (#435)
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- **MCP tool-discovery failures are reported instead of running tool-less and
|
|
60
|
+
silent.** `MCPToolDispatcher#tool_definitions` rescued a failed `tools/list`
|
|
61
|
+
to `[]`, so a server that 401s and one that legitimately serves no tools
|
|
62
|
+
were indistinguishable: the agent ran without tools, the model fabricated,
|
|
63
|
+
and the report offered prompt advice for what was a transport failure.
|
|
64
|
+
`discovery_errors` now names the server, its URL and the underlying error,
|
|
65
|
+
and `all_servers_failed?` lets a caller fail loudly rather than grade an
|
|
66
|
+
invented answer. (#434, closes #425)
|
|
67
|
+
|
|
68
|
+
- **Nil VCR filters no longer flake replays**, and the MCP plural is spelled
|
|
69
|
+
correctly. (#436)
|
|
70
|
+
|
|
71
|
+
## [1.5.1] - 2026-09-11 [UNRELEASED — superseded by 1.5.2]
|
|
72
|
+
|
|
73
|
+
Bumped `activeagent` to 1.5.1 and held `actionagent` at 1.5.0. Never tagged;
|
|
74
|
+
its contents ship in 1.5.2.
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- **A spec hash names its model rather than reaching the provider as an
|
|
79
|
+
inspected Hash.** `Evals::ModelSpec.parse_all` called `to_s` on each value,
|
|
80
|
+
so a Hash travelled as the model ID and the provider answered
|
|
81
|
+
`{"label" => "openrouter/openai/gpt-4o-mini", ...} is not a valid model ID`
|
|
82
|
+
— every scenario of the run failing before it reached the model. A caller
|
|
83
|
+
passing a plain string was unaffected, which is why a single run worked
|
|
84
|
+
while a whole suite failed. The path is reachable by design rather than by
|
|
85
|
+
misuse: a run persists its models as `specs.map(&:to_h)`, so re-running that
|
|
86
|
+
selection hands the hashes back. `parse_all` now reads a hash's `label`,
|
|
87
|
+
then its `model`, and leaves strings alone.
|
|
88
|
+
|
|
89
|
+
## [1.5.0] - 2026-09-10
|
|
90
|
+
|
|
91
|
+
Releases `activeagent` 1.5.0 and `actionagent` 1.5.0 from one tag.
|
|
92
|
+
|
|
93
|
+
`actionagent` goes from 1.3.0 to 1.5.0, skipping 1.4: the two gems are
|
|
94
|
+
released together from this repository and from one tag, and carrying one
|
|
95
|
+
version number across both is less confusing than explaining which
|
|
96
|
+
dashboard version pairs with which framework. `actionagent` 1.4 does not
|
|
97
|
+
exist and never will. The engine's floor on the framework
|
|
98
|
+
(`activeagent >= 1.4`) is unchanged and still correct.
|
|
99
|
+
|
|
100
|
+
### Added
|
|
101
|
+
|
|
102
|
+
- **`ActiveAgent::Evals::Publisher` delivers a finished report to a
|
|
103
|
+
collector.** A run that already happened — in CI, in a host app's own
|
|
104
|
+
runtime, anywhere the evaluation core runs — can be sent to an
|
|
105
|
+
ActiveAgents-compatible collector without replaying the agent:
|
|
106
|
+
`Publisher.new(api_key:, endpoint:).call(report:, run_id:, source:,
|
|
107
|
+
agent_name:, suite:)` posts a version-1 envelope wrapping `Report#to_h`
|
|
108
|
+
(or the saved JSON hash of an earlier run) and returns the collector's
|
|
109
|
+
receipt. Delivery is synchronous, requires HTTPS outside loopback, does
|
|
110
|
+
not follow a redirect carrying the bearer credential, caps a request at
|
|
111
|
+
2 MiB, and raises `Publisher::Error` on anything but a receipt naming the
|
|
112
|
+
same `run_id` — so a retry with that same `run_id` and the saved report
|
|
113
|
+
re-delivers rather than re-runs. Publication is strictly opt-in and
|
|
114
|
+
happens only where an application writes the call: no configuration flag,
|
|
115
|
+
no callback, no default credential, and `api_key:` supplied explicitly at
|
|
116
|
+
the call site. That is deliberate, because the payload is the report
|
|
117
|
+
itself — every scenario's prompt, the agent's answers, and the tool calls
|
|
118
|
+
and their results — and whether that may leave the application is the
|
|
119
|
+
application's decision to make. Installing the gem sends nothing
|
|
120
|
+
anywhere. `docs/evals/publication.md` documents the envelope, the receipt
|
|
121
|
+
and the retry rules. (#414)
|
|
122
|
+
|
|
123
|
+
- **`Runner` takes `around_evaluation:` and `require_judge_scores:`.**
|
|
124
|
+
`around_evaluation:` is called with `(scenario, spec)` and a block, and
|
|
125
|
+
wraps the whole evaluation — the replay, the scoring, the judge calls
|
|
126
|
+
behind a recommendation — so a host can establish one trace context
|
|
127
|
+
across all of it and correlate a replay with the judging it triggered. It
|
|
128
|
+
must return the block's result; `on_result` runs after it returns, an
|
|
129
|
+
error it raises propagates to the caller, and `#evaluate` called directly
|
|
130
|
+
bypasses it, for a host doing its own scheduling. `require_judge_scores:`
|
|
131
|
+
(default `false`) settles what an unusable judge means. A judge that
|
|
132
|
+
raises or answers unscorably is skipped, and the scenario is then decided
|
|
133
|
+
on its rule scores alone — which reads as "the agent passed" when the
|
|
134
|
+
truth is "nobody graded the answer". Set it, and an otherwise passing
|
|
135
|
+
result whose `task_completion` or declared `llm_judge` criterion has no
|
|
136
|
+
usable score fails instead, with the new `judge_unavailable` fault naming
|
|
137
|
+
the unscored criteria and pointing at the judge's credentials, model and
|
|
138
|
+
JSON reply. A run with no judged criteria is unaffected. (#414)
|
|
139
|
+
|
|
140
|
+
- **A grouped suite imports as YAML or JSON, whole.** `ScenarioParser` read
|
|
141
|
+
a pasted list or a JSON array of scenarios; it now also reads the grouped
|
|
142
|
+
document `Suite` loads — `groups:` with per-group keys and display names,
|
|
143
|
+
scenarios carrying `key`, `prompt`, `notes`, `expect` and
|
|
144
|
+
`production_only` — from YAML or JSON, keeping every part of it.
|
|
145
|
+
`ScenarioParser.parse` and `.scenarios` gain `include_production_only:`,
|
|
146
|
+
which defaults to `true` to match `Suite`. The dashboard defaults it the
|
|
147
|
+
other way: post the document as `scenarios_text` and the production-only
|
|
148
|
+
questions stay out unless `include_production_only` is sent alongside it,
|
|
149
|
+
because those prompts run against a live agent. The choice is made at
|
|
150
|
+
import — the engine stores the scenarios it selected, not the source
|
|
151
|
+
document — so changing it means importing the document again. (#414)
|
|
152
|
+
|
|
153
|
+
- **`actionagent`: `ActionAgent.scenario_evaluation_adapter_resolver`.** A
|
|
154
|
+
host application with its own agent runtime can now run an evaluation
|
|
155
|
+
itself while keeping the dashboard's catalog, selection, jobs, result
|
|
156
|
+
persistence and report pages. The resolver is called with the persisted
|
|
157
|
+
evaluation and returns `nil` for the engine's normal `Agent#test_execute`
|
|
158
|
+
path, or a callable — `evaluation:`, `owner:`, `scenarios:`, `models:`,
|
|
159
|
+
`on_result:` — that runs the host's own agent and judge, yields every
|
|
160
|
+
result as it lands, and returns an `ActiveAgent::Evals::Report`. The
|
|
161
|
+
engine holds it to that contract: anything other than a `Report`, or a
|
|
162
|
+
report that omits or duplicates one of the selected scenario × model
|
|
163
|
+
pairs, fails the run rather than completing it with rows missing, and an
|
|
164
|
+
exception leaves the results already written in place. Dashboard
|
|
165
|
+
authentication, execution enablement and the host's execution quota still
|
|
166
|
+
apply. (#414)
|
|
167
|
+
|
|
168
|
+
- **`actionagent`: the Run Agent page is a conversation workbench.** Testing
|
|
169
|
+
an agent used to mean one prompt in, one output out, with no way to see —
|
|
170
|
+
or shape — what the model was given. The page now works the way a user
|
|
171
|
+
would work the agent: it pins a persisted conversation (a solid_agent
|
|
172
|
+
context) and every run sends that conversation's user and assistant turns
|
|
173
|
+
ahead of the new message, so follow-up questions actually follow up. The
|
|
174
|
+
context is editable in place — edit or delete a turn, seed a user or
|
|
175
|
+
assistant message without running, start a new conversation — and every
|
|
176
|
+
run is a fresh `AgentRun` with its own trace, so Traces and Interactions
|
|
177
|
+
see exactly what the model saw. Files attach to a message and ride along
|
|
178
|
+
through Active Storage (`AgentRun has_many_attached :attachments`, guarded
|
|
179
|
+
for hosts without it): images reach the model as vision input, PDFs as
|
|
180
|
+
documents, and text-like files (CSV, Markdown, JSON, plain text) are
|
|
181
|
+
inlined into the message; the persisted user message keeps an attachment
|
|
182
|
+
manifest so the conversation shows thumbnails afterwards. Assistant replies
|
|
183
|
+
can render **generative UI** — cards, stats, tables, charts, lists,
|
|
184
|
+
progress, forms, choice buttons, images, callouts and code — from a fenced
|
|
185
|
+
```` ```ui ```` JSON block in prose, a JSON reply whose top level is
|
|
186
|
+
`ui`/`blocks`, or the new `render_ui` tool (enable the **Generative UI**
|
|
187
|
+
tool on the agent). Forms and choices post their answer back into the
|
|
188
|
+
conversation as the next user message. New engine API: `GET/POST
|
|
189
|
+
/api/agents/:id/conversations`, message create/update/delete under
|
|
190
|
+
`/api/interactions/:id/messages`, multipart `POST /api/agents/:id/execute`
|
|
191
|
+
with `attachments[]` and `params[context_id]`, and attachment metadata on
|
|
192
|
+
run and message JSON. The reference host (`test/dummy`) gained the Active
|
|
193
|
+
Storage tables so the attachment path is exercised by the engine's tests.
|
|
194
|
+
Two notes for anyone driving that API directly: `execute`/`test` now answer
|
|
195
|
+
422 unless the request carries a prompt or a file, and per-run overrides in
|
|
196
|
+
`params` can no longer name `attachments` or `action` — those stay the
|
|
197
|
+
controller's to set. Model-supplied images in generative UI load on sight
|
|
198
|
+
only when they are inline data or this app's own URL; any other host is
|
|
199
|
+
offered as a click-to-load, since fetching one tells that host whatever the
|
|
200
|
+
model put in the URL.
|
|
201
|
+
|
|
202
|
+
### Fixed
|
|
203
|
+
|
|
204
|
+
- **A scenario passes only if it completed the task.** A scenario's verdict
|
|
205
|
+
was the mean of everything scored for it, and the judge's
|
|
206
|
+
`task_completion` grade was one number in that mean: an answer that
|
|
207
|
+
called the expected tool and contained the expected string could carry a
|
|
208
|
+
task grade of 0.2 to a mean of 0.73 and pass at the default threshold of
|
|
209
|
+
0.7. `task_completion` is a gate now — it has to reach `threshold` on its
|
|
210
|
+
own, and no number of passing tool and content checks can lift it — and
|
|
211
|
+
the fault names the number that failed: "Task completion scored 0.2
|
|
212
|
+
against a pass threshold of 0.7". An evaluation that configures its own
|
|
213
|
+
`llm_judge` criteria rather than relying on the implicit grade — which is
|
|
214
|
+
what the dashboard does — is gated the same way, on the mean of those
|
|
215
|
+
grades, so one soft dimension among strong ones still passes while an
|
|
216
|
+
answer the judge marked down cannot be carried by its mechanics. A
|
|
217
|
+
scenario the judge could not grade at all is unchanged, still falling
|
|
218
|
+
back to the rule scores. `score` and `avg_score` still mean the aggregate
|
|
219
|
+
they always did, and each model's
|
|
220
|
+
summary gains `avg_task_completion` so the judge's grade reads separately
|
|
221
|
+
from it. **This can turn a suite that passed on 1.4.0 red; see the note
|
|
222
|
+
on upgrading below.** (#414)
|
|
223
|
+
|
|
224
|
+
- **A judge's score is read as a JSON number.** The score was pulled out of
|
|
225
|
+
the judge's reply by regular expression, matching the first run of digits
|
|
226
|
+
after `"score":`. It read `{"score": 9e-2}` — 0.09 — as 9, clamped to a
|
|
227
|
+
perfect 1.0; it read the string `{"score": "0.9"}` and the truncated
|
|
228
|
+
`{"score": 0.9oops}` as a confident 0.9 rather than as unusable. The
|
|
229
|
+
score now comes from the parsed JSON object and has to be a finite
|
|
230
|
+
number, so exponent notation is read as written and a string, a boolean,
|
|
231
|
+
`null`, `NaN` or `1e999` is unscorable — which the runner already knows
|
|
232
|
+
how to handle. Fenced ```` ```json ```` replies still parse. The
|
|
233
|
+
dashboard's generation-sampling evaluations score through the engine's own
|
|
234
|
+
judge rather than the framework's, and read a score by the same rule now,
|
|
235
|
+
so the two halves of the dashboard no longer disagree about the same
|
|
236
|
+
reply. (#414)
|
|
237
|
+
|
|
238
|
+
- **A judge that answers with the wrong types cannot put junk in the fix
|
|
239
|
+
list.** `suggested_tool` and `instruction_change` were coerced rather
|
|
240
|
+
than checked, so a reply of `"suggested_tool": {"name": true}` added a
|
|
241
|
+
tool literally named `true` to the report's suggested tools, and
|
|
242
|
+
`"instruction_change": ["invalid"]` became a fix card asking someone to
|
|
243
|
+
add `["invalid"]` to the agent's instructions. Both fields must now be
|
|
244
|
+
nonempty strings and are dropped when they are not, so a malformed reply
|
|
245
|
+
loses only the malformed part: the judge's recommendation still reaches
|
|
246
|
+
the result, the report and every rendering of it. (#414)
|
|
247
|
+
|
|
248
|
+
- **A grouped suite pasted into the dashboard keeps its keys and
|
|
249
|
+
expectations.** Only a pasted list or JSON was recognised, so a YAML suite
|
|
250
|
+
went to the line parser and was read as prose: a document describing three
|
|
251
|
+
scenarios became eighteen, with prompts like `tools: [lookup_order]` and
|
|
252
|
+
`production_only: true`, groups named `expect`, generated keys in place of
|
|
253
|
+
the document's own, and every expectation dropped — a suite that looked
|
|
254
|
+
imported and scored nothing real. Such a document is now parsed as the
|
|
255
|
+
suite it is, and one that is not valid, or a selection that matches no
|
|
256
|
+
scenarios, returns an import error (`ScenarioParser::ParseError`, HTTP
|
|
257
|
+
422) instead of a suite of nonsense or a sampling evaluation nobody asked
|
|
258
|
+
for. (#414)
|
|
259
|
+
|
|
260
|
+
- **`actionagent`: run and result metadata survive persistence.** A run
|
|
261
|
+
rebuilt from the database was rebuilt without it: `Report#metadata` came
|
|
262
|
+
back holding only the four keys the engine writes itself, and each
|
|
263
|
+
result's replay metadata was gone entirely, so a host's own run and result
|
|
264
|
+
IDs, response trace IDs and judge trace IDs did not survive the round trip
|
|
265
|
+
and its reports could not be joined to its telemetry. Run metadata is now
|
|
266
|
+
kept in `scores["_metadata"]` and per-result metadata in
|
|
267
|
+
`diagnosis["_replay_metadata"]`, restored by `EvaluationRun#to_report` and
|
|
268
|
+
served as `metadata` on result JSON. Both are reserved storage keys that
|
|
269
|
+
the public diagnosis excludes, so nothing migrates and `diagnosis` still
|
|
270
|
+
means what it did. (#414)
|
|
271
|
+
|
|
272
|
+
- **`actionagent`: refreshing a catalog does not rewrite what an earlier run
|
|
273
|
+
asked.** A saved run rendered its scenarios from the catalog rows as they
|
|
274
|
+
are now, so rewording a question, retagging its group or changing its
|
|
275
|
+
expectations silently rewrote history — last month's report showed this
|
|
276
|
+
month's prompt above last month's answers, and judged them against
|
|
277
|
+
expectations that were not in force when they were given. Each result now
|
|
278
|
+
records the scenario it was actually evaluated against in
|
|
279
|
+
`diagnosis["_scenario_snapshot"]`, and the report, the API and the
|
|
280
|
+
scenario matrix read that snapshot, in the order the run itself used, with
|
|
281
|
+
the dashboard noting on a scenario whose catalog entry has since changed
|
|
282
|
+
that re-running uses the current one. A run records its judge the same
|
|
283
|
+
way, in `scores["_judge_label"]`: `Report#to_h` and `#to_markdown` now
|
|
284
|
+
name the judge a rebuilt run was given instead of reporting "No judge" for
|
|
285
|
+
every run reconstructed from the database. Results saved before this
|
|
286
|
+
release carry no snapshot and still render from the current catalog, and a
|
|
287
|
+
link to a saved report (`?evaluation=:id&run=:run_id`) now opens its
|
|
288
|
+
evaluation even when it is no longer on the first page of the index.
|
|
289
|
+
(#414)
|
|
290
|
+
|
|
291
|
+
- **`actionagent`: an observed agent cannot be made executable.** An agent
|
|
292
|
+
discovered from telemetry has no configuration to run, and `execute` and
|
|
293
|
+
`test` refused one — but `update` and `restore` did not, so an observed
|
|
294
|
+
record could be flipped to `active`, given instructions and then run; and
|
|
295
|
+
a run queued against an agent that became observed afterwards still
|
|
296
|
+
reached a provider when its job came up. The refusal now covers `update`
|
|
297
|
+
and `restore` as well, and it is enforced under the API rather than only
|
|
298
|
+
in front of it: `Agent#execute`, `#test_execute` and
|
|
299
|
+
`AgentExecutionService#call` raise
|
|
300
|
+
`ActionAgent::Agent::ObservedAgentError`, so that queued job fails its run
|
|
301
|
+
without a provider call or a trace. Duplicating the agent still gives you
|
|
302
|
+
an executable copy, and an evaluation whose host explicitly resolves an
|
|
303
|
+
adapter for it remains the one path that replays an observed agent's
|
|
304
|
+
scenarios. (#414)
|
|
305
|
+
|
|
306
|
+
- **The OpenAI Responses API keeps images and documents on a message with a
|
|
307
|
+
role.** `{ role: "user", text: "…", image: "…" }` — the shorthand the Chat
|
|
308
|
+
API and Anthropic transforms accept, and the only provider-neutral way to
|
|
309
|
+
send history followed by a multimodal turn — lost its `image:` or
|
|
310
|
+
`document:` on the provider the framework defaults to, because the
|
|
311
|
+
Responses transform kept only `content` from a role-bearing hash. It now
|
|
312
|
+
builds `input_text` / `input_image` / `input_file` parts for it, and a
|
|
313
|
+
media-only `{ role: "user", image: "…" }` becomes a message with one part.
|
|
314
|
+
The shorthand keys always come off the message, so a hash that carries
|
|
315
|
+
`content` *and* `image:` no longer sends `image` as an unknown parameter,
|
|
316
|
+
and a blank `image:`/`document:` contributes no part rather than an empty
|
|
317
|
+
one. A nil `document:` alongside a role was the unknown-parameter case; the
|
|
318
|
+
crash needed the role-less `{ document: nil }` inside a content array, which
|
|
319
|
+
called `start_with?` on nil.
|
|
320
|
+
|
|
321
|
+
- **`actionagent`: a dashboard run's trace is attributed to the agent that
|
|
322
|
+
ran it.** Every locally stored run used to register an "observed" twin of
|
|
323
|
+
its own agent, because a run's class and action match no authored record.
|
|
324
|
+
The service that ran the agent now names it when it records the trace.
|
|
325
|
+
It is named by that caller and never read from the payload: resource
|
|
326
|
+
attributes are whatever the reporter sent, and single-tenant ingest is
|
|
327
|
+
unauthenticated unless `ActionAgent.ingest_api_key` is set, so an id taken
|
|
328
|
+
from there would let any reporter bind its traces to any authored agent by
|
|
329
|
+
guessing a primary key. A host that swaps in its own `trace_model` should
|
|
330
|
+
add the `agent:` keyword to its `create_from_payload`; without it the
|
|
331
|
+
dashboard logs the error and records no trace for its own runs. (#405)
|
|
332
|
+
|
|
333
|
+
- **`actionagent`: an observed agent's history cannot be authored.** The
|
|
334
|
+
runner's conversation workbench writes an agent's history without running
|
|
335
|
+
it, and those two endpoints — starting a conversation, and seeding, editing
|
|
336
|
+
or deleting a turn — did not answer to the read-only rule execution does.
|
|
337
|
+
A turn typed into a telemetry mirror would be a fabrication attributed to
|
|
338
|
+
an agent whose whole point is that it only reports what really happened.
|
|
339
|
+
Both refuse an observed agent now, with the same message and status
|
|
340
|
+
`execute` gives. Reading that history is unchanged. (#405)
|
|
341
|
+
|
|
342
|
+
### Note on upgrading from 1.4.0
|
|
343
|
+
|
|
344
|
+
A scenario suite that passed on 1.4.0 can fail on this release with nothing
|
|
345
|
+
about your agent, your models or your suite having changed. Nothing has
|
|
346
|
+
regressed: the numbers those runs passed on were wrong, and this release
|
|
347
|
+
stops averaging them away.
|
|
348
|
+
|
|
349
|
+
A scenario's score was the mean of every criterion scored for it, and the
|
|
350
|
+
judge's `task_completion` grade — its answer to "did this actually do what
|
|
351
|
+
was asked" — was one term in that mean, alongside the rule checks. An answer
|
|
352
|
+
that called the expected tool, called it successfully, and contained the
|
|
353
|
+
expected string scored 1.0, 1.0 and 0.2 for a mean of 0.73, and passed at
|
|
354
|
+
the default threshold of 0.7: the mechanics carried the answer. That is the
|
|
355
|
+
wrong answer to the question an evaluation exists to ask. The agent called
|
|
356
|
+
`lookup_order`, said "ABC-123", and still never told the customer where the
|
|
357
|
+
order was — and the suite went green.
|
|
358
|
+
|
|
359
|
+
From this release `task_completion` has to clear `threshold` on its own.
|
|
360
|
+
Expect the first run after upgrading to show fewer passes than the run
|
|
361
|
+
before it, concentrated in the scenarios whose answers were thin, evasive or
|
|
362
|
+
wrong while their mechanics were right. Each of those now carries the
|
|
363
|
+
`low_quality` fault with a summary naming the grade that failed — "Task
|
|
364
|
+
completion scored 0.2 against a pass threshold of 0.7" — and the judge's
|
|
365
|
+
recommendation for it, and each model's summary reports
|
|
366
|
+
`avg_task_completion` beside `avg_score`, so a drop in pass rate can be read
|
|
367
|
+
against the grade that caused it. Nothing else about scoring moved: a
|
|
368
|
+
scenario the judge could not grade still falls back to its rule scores
|
|
369
|
+
unless you opt into `require_judge_scores: true`, and a suite meant to be
|
|
370
|
+
scored on mechanics alone can run without a judge or at a lower `threshold`.
|
|
371
|
+
Read that first run as a new baseline rather than a regression — it is
|
|
372
|
+
measuring something the runs before it were not.
|
|
373
|
+
|
|
10
374
|
## [1.4.0] - 2026-09-09
|
|
11
375
|
|
|
12
376
|
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)
|
|
@@ -46,9 +46,23 @@ module ActiveAgent
|
|
|
46
46
|
# duplicates by label.
|
|
47
47
|
def self.parse_all(values, **options)
|
|
48
48
|
values = values.to_s.split(",") unless values.is_a?(Array)
|
|
49
|
-
values.map { |value| value
|
|
49
|
+
values.map { |value| coerce(value) }.reject(&:blank?).uniq.map { |value| parse(value, **options) }
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# The label of one requested model. A caller that round-trips a spec —
|
|
53
|
+
# the dashboard persists `specs.map(&:to_h)` and hands it back on a
|
|
54
|
+
# re-run — sends a Hash, whose `to_s` is the inspected Hash and reaches
|
|
55
|
+
# the provider as the model ID: `{"label" => "openrouter/gpt-4o-mini",
|
|
56
|
+
# ...} is not a valid model ID`.
|
|
57
|
+
#
|
|
58
|
+
# @return [String, nil] nil when the value names no model
|
|
59
|
+
def self.coerce(value)
|
|
60
|
+
return value.to_h.stringify_keys.values_at("label", "model").compact.first.to_s.strip if value.respond_to?(:to_h) && !value.is_a?(String)
|
|
61
|
+
|
|
62
|
+
value.to_s.strip
|
|
63
|
+
end
|
|
64
|
+
private_class_method :coerce
|
|
65
|
+
|
|
52
66
|
# The provider a bare model name runs under. A rule whose provider the
|
|
53
67
|
# caller does not offer is skipped, so an app without Ollama does not
|
|
54
68
|
# route `name:tag` there.
|
|
@@ -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 =
|