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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9cf80ba8d14c538e4f9193bf423cb82c0d50223362547df2bd3bfb2c66c62f86
4
- data.tar.gz: 9f3bb57f1f22de2ee5c74350d601b459f4864a9daf2aa7198900ad0f3e44589a
3
+ metadata.gz: 7401cdff6a7895238a082383f2ef32683b743946445e11dacbf377470bf31071
4
+ data.tar.gz: b0f847d04eedf620c7f5320950d65e5ffc712296ef7db2b7ea6be25f3bc433b7
5
5
  SHA512:
6
- metadata.gz: 6f817eeba3d15db4b49e924457aa510d0c4623031994e96a72ffb566aa4b800c43209682333f90aa06dc49915e03fb640f03aed7a7d8b23bae30c8cc2e945d17
7
- data.tar.gz: 788b6ad2472a4faf7e8a81bfc36452268f14c7d4958af283d937417686ebd9c652c9f0c43504d16c6eb07e5852a9b2b158dd0b2b4a3038e48296efbeb2025e58
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
- 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)
@@ -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 =
@@ -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 its
12
- # mean score reached `threshold`; anything else carries exactly one fault
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, metadata: {})
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
- evaluate(scenario, spec).tap { |result| @on_result&.call(result) }
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) ? parse_json(stripped) : parse_lines(stripped)
50
- assign_keys(scenarios)
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
 
@@ -25,6 +25,7 @@ require_relative "evals/design_tokens"
25
25
  require_relative "evals/report_html"
26
26
  require_relative "evals/report"
27
27
  require_relative "evals/runner"
28
+ require_relative "evals/publisher"
28
29
 
29
30
  # Scenario evaluations for agents that answer with tools.
30
31
  #
@@ -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 # Tool-loop safety
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
- # For image/document, MockProvider doesn't support these, so ignore
25
- # (or could raise an error)
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 - convert :text to :content
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
- if normalized.key?(:text) && !normalized.key?(:content)
302
- normalized[:content] = normalized.delete(:text)
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.key?(:image)
331
+ if message[:image].present?
309
332
  { type: "input_image", image_url: message[:image] }
310
- elsif message.key?(:document)
311
- document_value = message[:document]
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|
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.4.0"
2
+ VERSION = "1.5.0"
3
3
  end
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.0
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: 2026-09-09 00:00:00.000000000 Z
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: 3.5.22
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: []