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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9cf80ba8d14c538e4f9193bf423cb82c0d50223362547df2bd3bfb2c66c62f86
4
- data.tar.gz: 9f3bb57f1f22de2ee5c74350d601b459f4864a9daf2aa7198900ad0f3e44589a
3
+ metadata.gz: 73e63c87b700b6dec4e3410ae0547ff1c02714fbe79b24175447fb5638a9fe3b
4
+ data.tar.gz: ef361dc3bb7dcab252c0fadf1fb526e689b97b21386a2d50b6ba6eb96a84100a
5
5
  SHA512:
6
- metadata.gz: 6f817eeba3d15db4b49e924457aa510d0c4623031994e96a72ffb566aa4b800c43209682333f90aa06dc49915e03fb640f03aed7a7d8b23bae30c8cc2e945d17
7
- data.tar.gz: 788b6ad2472a4faf7e8a81bfc36452268f14c7d4958af283d937417686ebd9c652c9f0c43504d16c6eb07e5852a9b2b158dd0b2b4a3038e48296efbeb2025e58
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
- def self.call(scenario:, replay:, scores:, score:, available_tools:, threshold: PASS_THRESHOLD, agent_name: "The agent")
60
- new(scenario:, replay:, scores:, score:, available_tools:, threshold:, agent_name:).call
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
- return nil if @score.nil? || @score >= @threshold
217
-
218
- weakest = @scores.compact.min_by { |_, value| value }
219
- summary = "Scored #{@score.round(2)} against a pass threshold of #{@threshold}"
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
- { "name" => tool["name"].to_s, "description" => tool["description"].to_s } if tool["name"].present?
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
- match = content.to_s.match(/"score"\s*:\s*(\d+(?:\.\d+)?)/)
191
- match && match[1].to_f.clamp(0.0, 1.0)
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.to_s.strip }.reject(&:blank?).uniq.map { |value| parse(value, **options) }
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
- lines << (@judge ? "Judged by `#{@judge.label}`." : "No judge; scored on rules and expectations alone.")
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 Clara", "unknown · not enabled for
312
- # Clara" — every status but "enabled" leads with the status word, the
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 =