activeagent 1.9.2 → 1.9.3

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: 2b52709687c94c3d6c989fc36bba7826b931a6d0aa6e193b1bd4482f916d945f
4
- data.tar.gz: 1801338ff01afc21837db5382aee49eb4cde1f002b322b479cf2256c283e8668
3
+ metadata.gz: a7ecc64cad33188dd3cdfc563b553e49d0a05d42192698365a467061e6bb6d59
4
+ data.tar.gz: c3923cee8e401e17e3170c28f2fc83d8c290d70c5a4bc4d301951031d7d9cf43
5
5
  SHA512:
6
- metadata.gz: 4d0299374c70e7e66b5edf3b141d171e79cb2f576ec32dcb6a2b4e11e9eb5bccaabbf2a5106fb5afd05f01ece8b6dc75e9a0cd956523c39ffdc4c3b5a17a0b22
7
- data.tar.gz: b60f377af9288f08cd5166ba7d71edc1060ccddf23c1d2912ed4b9d105e75ce6a6325a1f0e855e2141ded3c2ebdffbb7cd0e2f37796e662cc37ef833054d21d1
6
+ metadata.gz: 66e3244ce04d56625f880e26c18cbe3f353f35eca2f4dd5922a1adafb1d542ee5216065401e7ddbbe88cccc1c40336076ab6d4a30d54a5360846cad595ae9717
7
+ data.tar.gz: b023e5fe43f6674b7040086cc5a47c19fffc4bf3e919d0bb0ab0138d5c4839d3463f09e5a66d7047e4e28262628465a066b343ee76a503732b803ddc5d270bff
data/CHANGELOG.md CHANGED
@@ -5,6 +5,208 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.9.3] - 2026-10-21
9
+
10
+ Releases `activeagent` and `actionagent` 1.9.3, with the matching browser
11
+ sidecar: browser upload and code conversations (the sandbox browser's live
12
+ view takes files from the person driving it, and a Claude Code or Codex
13
+ session is a conversation with the sandbox's browser beside the chat and a
14
+ hand-off either way, Codex on this machine's own login), evaluation run
15
+ reports exported as JSON, HTML and Markdown with Copy as Markdown, an agent
16
+ builder that offers only tools that work, a model leaderboard on the
17
+ Evaluations page, complete trace window statistics, a judge pin that runs
18
+ on its own provider, Opper as a provider and Parallel Search in the MCP
19
+ catalog, and streaming fixes (unindexed tool call fragments, the finish
20
+ reason of streamed and Responses generations, Anthropic cache-token usage).
21
+ The framework, the dashboard engine and `@activeagents/browser-sidecar`
22
+ move to 1.9.3 together.
23
+
24
+ Upgrading: migration 028 adds the conversation columns to `code_sessions`.
25
+ A host runs `bin/rails generate action_agent:install --skip` then
26
+ `bin/rails db:migrate`, after 1.9.1's 027. Upgrade the sidecar with the
27
+ engine: the file chooser relay and the per-turn browser tokens are in the
28
+ 1.9.3 sidecar. On a single-tenant install the new report export
29
+ (`GET <mount>/api/evaluation_reports/:run_id`) answers 401 until
30
+ `ActionAgent.ingest_api_key` is set. A `judge_model` pin written
31
+ `anthropic/…` or `openai/…` now runs on that native provider; an install
32
+ that reached such a model through OpenRouter pins `openrouter/anthropic/…`.
33
+
34
+ ### Added
35
+
36
+ - **Export evaluation run reports as JSON, HTML and Markdown** (`activeagent`,
37
+ `actionagent`). `GET <mount>/api/evaluation_reports/:run_id`, with the
38
+ ingest key and scoped to the tenant like the POST (and refused on a
39
+ single-tenant install that set no key), returns a complete run
40
+ as the version-1 envelope the collector takes, so a run moves to another
41
+ install with one POST and posting it back is an identical retry (200);
42
+ `?format=html` returns the run's report page and `?format=md` its
43
+ Markdown, and `?download=1` an attachment of any of them.
44
+ An imported run exports under its published `run_id`, a dashboard run
45
+ under `dashboard-run-<id>-<created_at>`. `ActiveAgent::Evals::Publisher.envelope`
46
+ builds the envelope for both the publisher and the export. The dashboard's
47
+ run report adds `report.json` and `report.md` (one piece with
48
+ `?part=verdict|fix_items|scenario`), and `?download=1` saves any format
49
+ as `<evaluation>-run-<n>.<ext>`; the run report view and the suite's run
50
+ header get *Download HTML / JSON / Markdown*. New rake tasks:
51
+ `action_agent:evaluations:export RUN=… [EVALUATION=…] FORMAT=json|html|md [FILE=…]`
52
+ and `action_agent:evaluations:import FILE=… [ACCOUNT_ID=…]`.
53
+
54
+ - **Copy as Markdown** (`activeagent`, `actionagent`). `Report#to_markdown`
55
+ now has the HTML page's content in GitHub-flavored Markdown: the verdict's
56
+ rationale, What to fix from `Report#fix_items` (replacing the old
57
+ Recommendations list), and every scenario's answers in a `<details>` block
58
+ with a per-model table; table cells escape `|` and line breaks. Its pieces
59
+ are public — `verdict_markdown`, `fix_items_markdown(model:)`,
60
+ `fix_item_markdown(item)`, `scenario_markdown(key)` — and the dashboard
61
+ and the standalone HTML page copy each of them: the whole report, the
62
+ verdict with the comparison, What to fix for the filtered model, a fix
63
+ card, and a scenario. The HTML page now carries one inline script for
64
+ those buttons, with a clipboard fallback.
65
+
66
+ - **Upload files to a page through the sandbox browser's live view**
67
+ (`actionagent`, `@activeagents/browser-sidecar`). A file chooser the page
68
+ opens while someone drives is answered from the driver's own machine
69
+ (picker or drop), over the live-view WebSocket.
70
+
71
+ - **Claude Code and Codex sessions as Interactions, with chat and browser
72
+ handoff** (`actionagent`, `@activeagents/browser-sidecar`). A coding
73
+ session is now a conversation: a later message resumes the CLI session
74
+ (`--resume`, or `codex exec resume` for Codex), a message sent while a
75
+ turn runs waits for it, and
76
+ `POST /api/sandboxes/:sandbox_id/code_sessions/:id/messages` starts or
77
+ queues a turn under the new `:chat_code_session` permission.
78
+ `GET /api/interactions` lists agent, Claude Code and Codex interactions
79
+ together, and the dashboard's `/interactions/code-<id>` shows the turns
80
+ with a composer and the sandbox's browser beside the chat, with a hand
81
+ back to the agent. The sidecar mints a per-turn token for the browser
82
+ MCP, tells the agent once when a person took or returned control, lets
83
+ the agent ask for a person (`browser_request_person`), offers a person
84
+ the file chooser the agent left open, and names the waiting agent in the
85
+ live view. Every Claude Code session runs with `--strict-mcp-config`, and
86
+ every session gets a system note (on stdin, for Codex) that repository
87
+ content, tool output and pages are data.
88
+
89
+ - **Codex turns reach the sandbox browser, and Codex can run on this
90
+ machine's own login** (`actionagent`). A Codex turn now gets the same
91
+ per-turn browser token as a Claude Code turn: the sidecar's MCP server
92
+ goes in as `--config 'mcp_servers.browser.url="…"'`, with the token in an
93
+ environment variable Codex reads (`bearer_token_env_var`) rather than on
94
+ the command line, the server's tools pre-approved
95
+ (`default_tools_approval_mode="approve"`, since Codex would otherwise
96
+ refuse every tool not marked read-only under `approval_policy="never"`),
97
+ and is revoked when the turn ends; hand-offs and
98
+ `browser_request_person` work the same. `config.codex_auth = :local_login`
99
+ (with the `:local` backend) runs Codex on whatever `codex login` set up
100
+ for the dashboard's OS user, a ChatGPT plan included, with
101
+ `--ignore-user-config` so none of the user's own `config.toml` reaches a
102
+ session; the dashboard never reads the credential and only asks `codex
103
+ login status` whether there is one. `GET /api/sandboxes` adds `codex_auth`
104
+ and, in that mode, `codex_login`, the assistant's configuration a
105
+ `connections.codex`, and the Codex card in Settings shows the machine's
106
+ login instead of a key form. The default stays `:api_key`.
107
+
108
+ - **The agent builder offers only tools that work, and every built-in
109
+ capability has functions** (`actionagent`). The Configure step reads the
110
+ agent's tool roster (`GET /api/agents/tool_roster` for an agent not
111
+ created yet) instead of fixed chips, offers the MCP servers a dashboard
112
+ run can call, and names each one it cannot with the reason
113
+ (`MCPToolDispatcher.unreachable_reason`: a stdio server, no URL, missing
114
+ credentials). The six capabilities that gave the model nothing now have
115
+ functions: `terminal` (`run_command`), `filesystem` (`list_files`,
116
+ `read_file`, `write_file`), `edit` (`edit_file`) and `database`
117
+ (`query_database`, read-only) act in the run's checkout sandbox through
118
+ new `SandboxOrchestrator` verbs (on the `:local` backend; others return
119
+ `UnsupportedBackendError` to the model); `translate` (`translate_text`)
120
+ generates on the run's own provider; `slack` lists, reads and posts with
121
+ a bot token connected in Settings -> Integrations -> Slack (a `slack`
122
+ connection `ProviderKey`). Instruction sets are composed into the
123
+ instructions a run executes under. `run_command`, `write_file`,
124
+ `edit_file` and `slack_post_message` wait for approval by default: an
125
+ agent that gains `terminal`, `filesystem`, `edit` or `slack` — from the
126
+ builder, a template, the Tools tab or the agents API — has them on its
127
+ `approval_required_tools` unless the save names its own list, and the
128
+ Tools tab's Approval switch takes them off. An agent that enabled one of
129
+ those capabilities before this release keeps its list as it is. A host's
130
+ `ActionAgent.mcp_catalog` headers now reach the MCP client (they were
131
+ dropped).
132
+
133
+ - **Start an interaction with your own agent from Interactions and Run
134
+ Agents** (`actionagent`). Interactions has a "New interaction" picker and
135
+ Run Agents a "Your agents" list; both open the agent's runner on a fresh
136
+ conversation (`/agents/:id/run?new=1`). The runner lists the agent's tools
137
+ and MCP servers, and can run against a checkout sandbox (`sandbox_id`),
138
+ which the checkout tools need.
139
+
140
+ - **A model leaderboard on the Evaluations page** (`actionagent`). The Pass
141
+ rate tile listed each model's passes on a line of its own, a dozen lines
142
+ for seven models; it now shows the overall rate, the leading model and a
143
+ "compare N models" toggle, which opens a leaderboard below the tiles: one
144
+ row per model with its pooled passes and rate, and one column per counted
145
+ evaluation (`—` where the model did not run). Models rank by the lower
146
+ bound of the Wilson score interval (95%), so a lucky 7/8 does not outrank
147
+ 75/173.
148
+
149
+ - **Opper as an OpenAI-compatible provider** (`activeagent`). `service:
150
+ "Opper"` generates and embeds through Opper's compatibility endpoint
151
+ (`https://api.opper.ai/v3/compat`), with the key from `OPPER_API_KEY`
152
+ when none is configured, as the Requesty provider does (#583).
153
+
154
+ - **Parallel Search in the MCP catalog** (`actionagent`). An opt-in,
155
+ keyless `parallel` server (`web_search`, `web_fetch`) over HTTP; queries
156
+ and URLs go to parallel.ai. Every MCP HTTP request now sends
157
+ `User-Agent: ActionAgent/<version>` unless a header names another (#575).
158
+
159
+ ### Fixed
160
+
161
+ - **Trace totals and timeline buckets cover the whole filtered window**
162
+ (`actionagent`). `GET /api/traces` adds `total_count`, `returned_count`,
163
+ `truncated`, a `summary` (requests, errors, average latency, throughput
164
+ per minute) and zero-filled `buckets` computed over every trace the
165
+ filters match, independently of the capped detail list, and the Traces
166
+ view reports truncation and averages recorded request durations instead
167
+ of time-bucket means (part of #384).
168
+
169
+ - **A pinned evaluation judge runs on its own provider** (`actionagent`).
170
+ A `judge_model` pin selects the model's native provider, or the one its
171
+ qualifier names (`openrouter/anthropic/…`), instead of the first provider
172
+ with credentials; the qualifier is stripped before the request, the pin
173
+ wins over the credential resolver's default model, and a pinned route
174
+ with no credentials fails the run clearly when another provider is
175
+ configured. Native-to-gateway alias conversion stays out of scope (#431).
176
+ Upgrading: a pin written `anthropic/…` or `openai/…` now selects that
177
+ native provider, as candidate model names already did; an install that
178
+ reached such a model through OpenRouter pins `openrouter/anthropic/…`.
179
+
180
+ - **Streamed tool calls without an `index` are no longer split into
181
+ fragments** (`activeagent`). OpenAI-compatible servers (Gemini's
182
+ compatibility endpoint, some Ollama and OpenRouter backends) stream tool
183
+ calls without the `index` that ties argument fragments to their call, and
184
+ the Chat provider turned every fragment into its own tool call with
185
+ partial arguments. A fragment now continues the call with the same `id`,
186
+ or the last call when it has no `id` and no function name.
187
+
188
+ - **A streamed generation reports why it stopped** (`activeagent`).
189
+ `response.finish_reason` (and `stop_reason`), and the `finish_reason` of
190
+ the `prompt.active_agent` event, were `nil` for every streamed Chat and
191
+ Anthropic generation, since a stream has no response body to read it
192
+ from. The Chat provider now records the chunk's `finish_reason` and the
193
+ Anthropic provider the `message_delta`'s `stop_reason`, and the response
194
+ falls back to it.
195
+
196
+ - **OpenAI Responses generations report why they stopped** (`activeagent`).
197
+ `response.finish_reason` (and `stop_reason`) was `nil` for every
198
+ Responses generation, streamed or not. It is now
199
+ `incomplete_details.reason` when the response was cut short
200
+ (`max_output_tokens`, `content_filter`), else the response's `status`
201
+ (`completed`).
202
+
203
+ - **Anthropic usage with cache token fields no longer raises** (`activeagent`).
204
+ `Usage.from_provider_usage` recognised Anthropic's usage only by
205
+ `cache_creation` or `service_tier`; a payload carrying
206
+ `cache_read_input_tokens` or `cache_creation_input_tokens` without them
207
+ fell through to `Usage.new` and raised `UnknownAttributeError`. The cache
208
+ token fields now mark the payload as Anthropic's (#611).
209
+
8
210
  ## [1.9.2] - 2026-10-20
9
211
 
10
212
  Releases `activeagent` and `actionagent` 1.9.2, with the matching browser
data/README.md CHANGED
@@ -57,6 +57,9 @@ bundle add openai
57
57
  # Requesty (uses OpenAI-compatible API)
58
58
  bundle add openai
59
59
 
60
+ # Opper (uses OpenAI-compatible API)
61
+ bundle add openai
62
+
60
63
  # DeepSeek (uses OpenAI-compatible API)
61
64
  bundle add openai
62
65
 
@@ -26,7 +26,7 @@ module ActiveAgent
26
26
  #
27
27
  # @example Basic agent
28
28
  # class MyAgent < ActiveAgent::Base
29
- # generate_with :openai, model: "gpt-4"
29
+ # generate_with :openai, model: "gpt-5"
30
30
  #
31
31
  # def greet
32
32
  # prompt instructions: "Greet the user warmly"
@@ -112,7 +112,7 @@ module ActiveAgent
112
112
  # @return [void]
113
113
  #
114
114
  # @example
115
- # generate_with :openai, model: "gpt-4", temperature: 0.7
115
+ # generate_with :openai, model: "gpt-5"
116
116
  def self.generate_with(provider_reference, **agent_options)
117
117
  self.prompt_provider = provider_reference
118
118
 
@@ -94,11 +94,7 @@ module ActiveAgent
94
94
  # report may be a Report or its saved JSON hash. Full prompts, answers and
95
95
  # tool results are included; applications should make publication opt-in.
96
96
  def call(report:, run_id:, source:, agent_name:, suite:)
97
- identities = { "run_id" => run_id, "source" => source, "agent_name" => agent_name, "suite" => suite }
98
- identities.each do |key, value|
99
- raise ArgumentError, "#{key} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
100
- end
101
- body = encode(identities.merge("version" => 1, "report" => report_hash(report)))
97
+ body = encode(self.class.envelope(report: report, run_id: run_id, source: source, agent_name: agent_name, suite: suite))
102
98
  raise Error, "Evaluation report exceeds the 2 MiB delivery limit; publish a smaller selection" if body.bytesize > MAX_BYTES
103
99
 
104
100
  deliver("retain the report and run_id for retry") do
@@ -143,6 +139,24 @@ module ActiveAgent
143
139
  end
144
140
  end
145
141
 
142
+ # The version-1 envelope a collector stores: the four identities, the
143
+ # version and the report's hash. What #call sends, and what a
144
+ # collector's export of a stored run returns, so the export can be
145
+ # posted again. report may be a Report or its saved JSON hash.
146
+ #
147
+ # Raises ArgumentError for an identity that is not a nonempty string,
148
+ # or a report that is neither.
149
+ def self.envelope(report:, run_id:, source:, agent_name:, suite:)
150
+ identities = { "run_id" => run_id, "source" => source, "agent_name" => agent_name, "suite" => suite }
151
+ identities.each do |key, value|
152
+ raise ArgumentError, "#{key} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
153
+ end
154
+ hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
155
+ raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
156
+
157
+ identities.merge("version" => 1, "report" => hash)
158
+ end
159
+
146
160
  private
147
161
 
148
162
  # Posts +body+ to the endpoint with the bearer key and the delivery timeouts.
@@ -176,13 +190,6 @@ module ActiveAgent
176
190
  raise Error.new("Evaluation delivery failed (#{e.class}); #{guidance}", retryable: true)
177
191
  end
178
192
 
179
- def report_hash(report)
180
- hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
181
- raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
182
-
183
- hash
184
- end
185
-
186
193
  # Returns the envelope as JSON. A report holding invalid UTF-8, NaN,
187
194
  # Infinity or nesting over JSON's depth limit raises a non-retryable
188
195
  # Error, without the generator's message, which can quote the report.
@@ -8,6 +8,7 @@ module ActiveAgent
8
8
  # Markdown, or a self-contained HTML page (ReportHtml).
9
9
  class Report
10
10
  include ReportHtml
11
+ include ReportMarkdown
11
12
 
12
13
  # The judge a verdict names when the framework ranked the models by
13
14
  # pass rate itself, no judge having been available to rule on them.
@@ -307,24 +308,6 @@ module ActiveAgent
307
308
  JSON.pretty_generate(to_h, *args)
308
309
  end
309
310
 
310
- def to_markdown
311
- scenario_count = @results.map { |result| result.scenario.key }.uniq.size
312
- lines = [ "# Evaluation — #{scenario_count} scenario#{'s' unless scenario_count == 1} × #{@models.size} model#{'s' unless @models.size == 1}", "" ]
313
- label = @judge_label || @judge&.label
314
- lines << (label ? "Judged by `#{label}`." : "No judge; scored on rules and expectations alone.")
315
- lines << ""
316
- lines.concat(summary_table)
317
- lines << ""
318
- lines.concat(total_lines)
319
- lines << "**Best model: #{winner}**" if winner
320
- lines << ""
321
- lines.concat(comparison_lines)
322
- lines.concat(matrix_table)
323
- lines.concat(detail_lines)
324
- lines.concat(recommendation_lines)
325
- lines.join("\n")
326
- end
327
-
328
311
  private
329
312
 
330
313
  # Whether a model summary's cost is an estimate — any replay priced
@@ -559,7 +542,7 @@ module ActiveAgent
559
542
  latency = stats["avg_duration_ms"] ? "#{stats['avg_duration_ms']} ms" : "—"
560
543
  percentiles = stats["p50_duration_ms"] ? "#{stats['p50_duration_ms']} / #{stats['p95_duration_ms']} ms" : "—"
561
544
  cells = [
562
- "`#{label}`",
545
+ cell("`#{label}`"),
563
546
  Format.passes(stats["passed"], stats["scenarios"], style: :markdown),
564
547
  Format.score(stats["avg_score"]),
565
548
  latency,
@@ -568,7 +551,7 @@ module ActiveAgent
568
551
  Format.money(stats["cost"], estimated: estimated_cost?(stats)),
569
552
  per_thousand_cell(stats),
570
553
  (judge_cell(stats) if judged),
571
- faults.presence || "—"
554
+ cell(faults.presence || "—")
572
555
  ].compact
573
556
  "| #{cells.join(' | ')} |"
574
557
  end
@@ -617,13 +600,13 @@ module ActiveAgent
617
600
  lines = [ "## Comparison", "", "| Model | Pass rate | #{confidence} | Scenarios |", "|---|---|---|---|" ]
618
601
  comparison["ranking"].each do |label|
619
602
  stats = comparison["models"][label]
620
- lines << "| `#{label}` | #{rate_text(stats['pass_rate'])} | #{interval_text(stats['interval'])} | #{stats['scenarios']} |"
603
+ lines << "| #{cell("`#{label}`")} | #{rate_text(stats['pass_rate'])} | #{interval_text(stats['interval'])} | #{stats['scenarios']} |"
621
604
  end
622
605
  lines += [ "", "| Pair | Difference | #{confidence} | Shared scenarios | Decisive |", "|---|---|---|---|---|" ]
623
606
  comparison["pairs"].each do |pair|
624
607
  a, b = pair["models"]
625
608
  difference = pair["difference"] ? format("%+.1f points", pair["difference"]) : "—"
626
- lines << "| `#{a}` vs `#{b}` | #{difference} | #{interval_text(pair['interval'])} | #{pair['scenarios']} | #{pair['decisive'] ? 'yes' : 'no'} |"
609
+ lines << "| #{cell("`#{a}` vs `#{b}`")} | #{difference} | #{interval_text(pair['interval'])} | #{pair['scenarios']} | #{pair['decisive'] ? 'yes' : 'no'} |"
627
610
  end
628
611
  lines += [ "", comparison["conclusion"].to_s, "",
629
612
  "_#{confidence} from #{Format.delimited(method['resamples'])} resamples of whole scenarios (seed #{method['seed']})._", "" ]
@@ -642,16 +625,16 @@ module ActiveAgent
642
625
  # cost across every model, and in brackets what the judge spent on it.
643
626
  def matrix_table
644
627
  labels = @models.map(&:label)
645
- header = [ "| Scenario | #{labels.map { |label| "`#{label}`" }.join(' | ')} | Cost |", "|---|#{labels.map { '---' }.join('|')}|---|" ]
628
+ header = [ "| Scenario | #{labels.map { |label| cell("`#{label}`") }.join(' | ')} | Cost |", "|---|#{labels.map { '---' }.join('|')}|---|" ]
646
629
  rows = @results.group_by { |result| result.scenario.key }.map do |key, cohort|
647
630
  cells = labels.map do |label|
648
631
  result = cohort.find { |candidate| candidate.label == label }
649
632
  next "—" unless result
650
633
 
651
634
  mark = result.passed? ? "✅" : (result.errored? ? "⚠️" : "❌")
652
- [ mark, (Format.score(result.score) if result.score), result.fault&.tr("_", " ") ].compact.join(" ")
635
+ cell([ mark, (Format.score(result.score) if result.score), result.fault&.tr("_", " ") ].compact.join(" "))
653
636
  end
654
- "| `#{key}` #{cell(cohort.first.scenario.prompt.truncate(70))} | #{cells.join(' | ')} | #{scenario_cost_cell(key)} |"
637
+ "| #{cell("`#{key}` #{cohort.first.scenario.prompt.truncate(70)}")} | #{cells.join(' | ')} | #{scenario_cost_cell(key)} |"
655
638
  end
656
639
  header + rows
657
640
  end
@@ -664,30 +647,11 @@ module ActiveAgent
664
647
  text
665
648
  end
666
649
 
667
- # A prompt may contain " | " (ScenarioParser keeps it), which would
668
- # otherwise split the table cell.
650
+ # A prompt may contain " | " (ScenarioParser keeps it), and an answer
651
+ # or a label a line break, either of which would otherwise break the
652
+ # table row.
669
653
  def cell(text)
670
- text.to_s.gsub("|") { "\\|" }
671
- end
672
-
673
- def detail_lines
674
- lines = [ "", "## Answers", "" ]
675
- @results.each do |result|
676
- lines << "### `#{result.scenario.key}` · `#{result.label}` · #{result.status}" \
677
- "#{" · score #{Format.score(result.score)}" if result.score}#{" · #{answer_cost(result)}" if result.replay.cost}"
678
- lines << ""
679
- lines << "> #{result.scenario.prompt}"
680
- lines << ""
681
- if result.replay.tool_calls.any?
682
- lines << "Tools: #{result.replay.tool_calls.map { |call| "#{call['name']}#{' ✗' if call['error']}" }.join(', ')}"
683
- end
684
- lines << "Fault: #{result.fault.tr('_', ' ')} — #{result.summary} #{result.recommendation}" if result.fault
685
- lines << "Error: #{result.replay.error}" if result.replay.error
686
- lines << ""
687
- lines << (result.replay.answer.presence || "_(no answer)_").to_s.truncate(1_500)
688
- lines << ""
689
- end
690
- lines
654
+ text.to_s.gsub(/\s*\r?\n\s*/, " ").gsub("|") { "\\|" }
691
655
  end
692
656
 
693
657
  # "~$0.0243 · judge ~$0.0015": what one answer cost, and what judging
@@ -698,20 +662,6 @@ module ActiveAgent
698
662
  text += " · judge #{Format.money(judge_cost, estimated: estimated_usage?(result.judge_usage))}" if judge_cost
699
663
  text
700
664
  end
701
-
702
- # Renders after `detail_lines`, whose trailing blank line separates the two sections.
703
- def recommendation_lines
704
- return [] if recommendations.empty?
705
-
706
- lines = [ "## Recommendations", "" ]
707
- recommendations.each do |entry|
708
- lines << "- **#{entry['fault'].tr('_', ' ')}** ×#{entry['count']} (#{entry['scenario_keys'].join(', ')}): #{entry['recommendation']}"
709
- entry["suggested_tools"].each do |tool|
710
- lines << " - suggested tool `#{tool['name']}`: #{tool['description']}"
711
- end
712
- end
713
- lines << ""
714
- end
715
665
  end
716
666
  end
717
667
  end
@@ -5,10 +5,15 @@ require "cgi"
5
5
  module ActiveAgent
6
6
  module Evals
7
7
  # Report#to_html: the run as one self-contained page on the dashboard's
8
- # design system (DesignTokens) — inline styles, no scripts, no external
9
- # assets — so a run's outcome can be archived next to a CI run or handed
10
- # to a teammate, and so the dashboard can serve the same page for a
11
- # persisted run.
8
+ # design system (DesignTokens) — inline styles, no external assets — so a
9
+ # run's outcome can be archived next to a CI run or handed to a teammate,
10
+ # and so the dashboard can serve the same page for a persisted run.
11
+ #
12
+ # Its one inline script (COPY_SCRIPT) backs the "Copy as Markdown"
13
+ # buttons: the whole report, the verdict, What to fix (for the model the
14
+ # filter shows), each fix card and each scenario. The Markdown each copies
15
+ # is ReportMarkdown's, embedded in a hidden textarea, so the page copies
16
+ # exactly what the dashboard and the CLI write.
12
17
  #
13
18
  # Same content as Report#to_markdown, laid out the way the dashboard's
14
19
  # suite card is: header and stat tiles, the MODELS panel with the judge's
@@ -50,6 +55,8 @@ module ActiveAgent
50
55
  #{html_footer}
51
56
  </section>
52
57
  </main>
58
+ #{markdown_store("md-report", to_markdown)}
59
+ <script>#{COPY_SCRIPT}</script>
53
60
  </body>
54
61
  </html>
55
62
  HTML
@@ -237,7 +244,7 @@ module ActiveAgent
237
244
 
238
245
  <<~HEADER
239
246
  <header>
240
- <h1>#{h(title)}</h1>
247
+ <div class="title-row"><h1>#{h(title)}</h1>#{copy_button('md-report', label: 'Copy as Markdown', title: 'Copy the whole report as GitHub-flavored Markdown')}</div>
241
248
  <div class="chips">#{chips.join}</div>
242
249
  </header>
243
250
  HEADER
@@ -320,7 +327,8 @@ module ActiveAgent
320
327
 
321
328
  <<~PANEL
322
329
  <div class="panel">
323
- <div class="panel-head"><span class="micro">Models</span><span class="right">judged by #{h(judged_by)}</span></div>
330
+ <div class="panel-head"><span class="micro">Models</span><span class="right">judged by #{h(judged_by)}</span>#{copy_button('md-verdict', title: 'Copy the verdict and the model comparison as Markdown')}</div>
331
+ #{markdown_store('md-verdict', verdict_markdown)}
324
332
  #{html_comparison_table if comparing?}
325
333
  #{html_intervals}
326
334
  #{blocks.join}
@@ -612,12 +620,13 @@ module ActiveAgent
612
620
  expects = scenario.expected_tools.any? ? %(<span class="exp">expects <b>#{h(scenario.expected_tools.join(' or '))}</b></span>) : ""
613
621
  labels = @models.map(&:label)
614
622
  cards = cohort.sort_by { |result| labels.index(result.label) || labels.size }.map { |result| html_result_card(result) }
623
+ md_id = "md-#{anchor(scenario)}"
615
624
 
616
625
  <<~BLOCK
617
626
  <div id="#{h(anchor(scenario))}">
618
627
  <details>
619
628
  <summary><span class="chev">&gt;</span><span class="key">#{h(scenario.key)}</span><span class="prompt">#{h(scenario.prompt)}</span>#{expects}</summary>
620
- <div class="drill">#{cards.join}</div>
629
+ <div class="drill">#{cards.join}<div class="drill-copy">#{copy_button(md_id, title: "Copy #{scenario.key} as Markdown")}#{markdown_store(md_id, scenario_markdown(scenario.key, answer_limit: ANSWER_LIMIT))}</div></div>
621
630
  </details>
622
631
  </div>
623
632
  BLOCK
@@ -670,14 +679,16 @@ module ActiveAgent
670
679
  "#{plural(faulted.map { |result| result.scenario.key }.uniq.size, 'scenario')}"
671
680
  body =
672
681
  if items.any?
673
- %(<div class="fixes">#{items.map { |item| html_fix_card(item) }.join}</div>)
682
+ %(<div class="fixes">#{items.each_with_index.map { |item, index| html_fix_card(item, index) }.join}</div>)
674
683
  else
675
684
  %(<div class="nothing">[+] nothing to fix</div>)
676
685
  end
677
686
 
678
687
  <<~FIXES
679
688
  <section class="section fix-section" aria-label="Recommendations">
680
- <div class="section-head"><span class="micro">What to fix</span><span class="meta">#{h(meta)}</span></div>
689
+ <div class="section-head"><span class="micro">What to fix</span><span class="meta">#{h(meta)}</span>#{copy_button('md-fixes', model_filtered: comparing? && items.any?, title: 'Copy What to fix as Markdown, for the model the filter shows')}</div>
690
+ #{markdown_store('md-fixes', fix_items_markdown)}
691
+ #{@models.each_index.map { |index| markdown_store("md-fixes-m#{index}", fix_items_markdown(model: @models[index].label)) }.join if comparing? && items.any?}
681
692
  #{html_fix_filter(items) if comparing? && items.any?}
682
693
  #{body}
683
694
  </section>
@@ -706,7 +717,7 @@ module ActiveAgent
706
717
  labels.filter_map { |label| (index = @models.index(model_by_label(label))) && "m#{index}" }.join(" ")
707
718
  end
708
719
 
709
- def html_fix_card(item)
720
+ def html_fix_card(item, index)
710
721
  tone = item["kind"] == "instruction" ? "info" : "error"
711
722
  glyph = tone == "info" ? "[i]" : "[!]"
712
723
  title = fault_name(item["fault"]) + (item["count"].to_i > 1 ? " ×#{item['count']}" : "")
@@ -719,6 +730,7 @@ module ActiveAgent
719
730
  parts << html_fix_server(item["server"]) if item["server"]
720
731
  parts << %(<div class="note">#{h(item['note'])}</div>) if item["note"].present?
721
732
  parts << html_fix_action(item["action"]) if item["action"]
733
+ parts << %(<div class="fix-copy">#{copy_button("md-fix-#{index}", title: 'Copy this fix as Markdown')}#{markdown_store("md-fix-#{index}", fix_item_markdown(item))}</div>)
722
734
  models = fix_model_tokens(item)
723
735
  %(<div class="fix"#{%( data-models="#{models}") if models.present?}>#{parts.join}</div>)
724
736
  end
@@ -771,6 +783,19 @@ module ActiveAgent
771
783
  "#{scenarios} · #{models}"
772
784
  end
773
785
 
786
+ # --- Copy as Markdown -------------------------------------------------
787
+
788
+ # A button COPY_SCRIPT answers by copying the textarea +id+ names; with
789
+ # +model_filtered+, the one for the model the fix filter has checked.
790
+ def copy_button(id, label: "Copy as Markdown", title: nil, model_filtered: false)
791
+ %(<button type="button" class="copy-md" data-md="#{h(id)}"#{' data-md-model' if model_filtered}) +
792
+ %(#{%( title="#{h(title)}") if title}>#{h(label)}</button>)
793
+ end
794
+
795
+ def markdown_store(id, markdown)
796
+ %(<textarea class="md-store" id="#{h(id)}" hidden readonly>#{h(markdown)}</textarea>)
797
+ end
798
+
774
799
  # --- footer ----------------------------------------------------------
775
800
 
776
801
  # The run's terms: the judge, the criteria and the pass mark, what it
@@ -804,6 +829,48 @@ module ActiveAgent
804
829
 
805
830
  # Colors only through the token variables; radii 4 badges · 6 chips ·
806
831
  # 8 controls · 10 nested panels · 12 cards · 999 bars; no shadows.
832
+ # Copies the Markdown a button names: the async Clipboard API where the
833
+ # page may use it, else a selected textarea and the copy command, which
834
+ # still works on an http:// host and in the dashboard's frame.
835
+ COPY_SCRIPT = <<~JS.gsub(/\n\s*/, "").freeze
836
+ document.addEventListener("click", function (event) {
837
+ var button = event.target.closest && event.target.closest("button.copy-md");
838
+ if (!button) return;
839
+ event.preventDefault();
840
+ var id = button.getAttribute("data-md");
841
+ if (button.hasAttribute("data-md-model")) {
842
+ var picked = document.querySelector('input[name="fix-model"]:checked');
843
+ if (picked && picked.value !== "all") id += "-" + picked.value;
844
+ }
845
+ var store = document.getElementById(id);
846
+ if (!store) return;
847
+ var text = store.value;
848
+ var label = button.getAttribute("data-label") || button.textContent;
849
+ button.setAttribute("data-label", label);
850
+ var done = function (ok) {
851
+ button.textContent = ok ? "Copied" : "Copy failed";
852
+ setTimeout(function () { button.textContent = label; }, 1500);
853
+ };
854
+ var legacy = function () {
855
+ var area = document.createElement("textarea");
856
+ area.value = text;
857
+ area.style.position = "fixed";
858
+ area.style.opacity = "0";
859
+ document.body.appendChild(area);
860
+ area.select();
861
+ var ok = false;
862
+ try { ok = document.execCommand("copy"); } catch (error) {}
863
+ document.body.removeChild(area);
864
+ done(ok);
865
+ };
866
+ if (navigator.clipboard && navigator.clipboard.writeText) {
867
+ navigator.clipboard.writeText(text).then(function () { done(true); }, legacy);
868
+ } else {
869
+ legacy();
870
+ }
871
+ });
872
+ JS
873
+
807
874
  STYLES = <<~CSS.freeze
808
875
  * { box-sizing: border-box; }
809
876
  html, body { margin: 0; min-height: 100%; }
@@ -913,6 +980,14 @@ module ActiveAgent
913
980
  .action { display: flex; align-items: center; gap: 10px; margin-top: auto; padding-top: 2px; flex-wrap: wrap; }
914
981
  .btn { display: inline-block; padding: 6px 12px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--color-text-cell); border: 1px solid var(--color-border-strong); background: transparent; white-space: nowrap; }
915
982
  .btn:hover { background: var(--color-hover); color: var(--color-text-primary); text-decoration: none; }
983
+ .copy-md { font-family: var(--font-mono); font-size: 11px; padding: 3px 8px; border-radius: 6px; border: 1px solid var(--color-border); background: transparent; color: var(--color-text-secondary); cursor: pointer; white-space: nowrap; }
984
+ .copy-md:hover { background: var(--color-hover); color: var(--color-text-primary); }
985
+ .title-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
986
+ .section-head .copy-md, .panel-head .copy-md { margin-left: auto; }
987
+ .panel-head .right + .copy-md { margin-left: 0; }
988
+ .drill-copy, .fix-copy { display: flex; justify-content: flex-end; }
989
+ .drill-copy { grid-column: 1 / -1; }
990
+ @media print { .copy-md { display: none; } }
916
991
  .hint { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-muted); white-space: nowrap; }
917
992
  .matrix { border: 1px solid var(--color-border-light); border-radius: 10px; overflow-x: auto; }
918
993
  .mx { display: grid; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--color-border-light); }
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Report#to_markdown: the run as GitHub-flavored Markdown with the same
6
+ # content as Report#to_html — the per-model table and the cost, the
7
+ # verdict with the model comparison, the scenario matrix, every answer
8
+ # (one `<details>` block per scenario, since GitHub strips the HTML
9
+ # page's styles), and What to fix from Report#fix_items — so it can be
10
+ # pasted into a pull request or an issue.
11
+ #
12
+ # Each piece is public on its own, for the pieces people paste one at a
13
+ # time: #fix_item_markdown, #fix_items_markdown (optionally for one
14
+ # model), #verdict_markdown and #scenario_markdown. The whole report is
15
+ # built from them, so a piece copied alone reads as it does in the report.
16
+ #
17
+ # Table cells escape `|` and fold line breaks; text set inside the HTML
18
+ # of a `<details>` block escapes `<`, `>` and `&`.
19
+ module ReportMarkdown
20
+ # How much of an answer the whole report quotes; a scenario copied on
21
+ # its own quotes the whole answer.
22
+ MARKDOWN_ANSWER_LIMIT = 1_500
23
+
24
+ def to_markdown
25
+ lines = [ "# #{markdown_title}", "" ]
26
+ lines << judged_by_line
27
+ lines << ""
28
+ lines.concat(summary_table)
29
+ lines << ""
30
+ lines.concat(total_lines)
31
+ lines.concat(verdict_lines)
32
+ lines.concat(comparison_lines)
33
+ lines.concat(matrix_table)
34
+ lines.concat([ "", "## Answers", "" ])
35
+ scenario_keys.each { |key| lines << scenario_markdown(key, details: true, answer_limit: MARKDOWN_ANSWER_LIMIT) << "" }
36
+ lines << fix_items_markdown(heading: "##")
37
+ "#{lines.join("\n").rstrip}\n"
38
+ end
39
+
40
+ # One fix item (Report#fix_items) as a heading and its body: the fault
41
+ # and how often, the scenarios and models it covers, the
42
+ # recommendation, the judge's proposed instruction change as a quote,
43
+ # the tools involved, the server that serves them, the note and where
44
+ # in the dashboard the fix lives.
45
+ def fix_item_markdown(item, heading: "###")
46
+ item = item.to_h.stringify_keys
47
+ title = item["fault"].to_s.tr("_", " ") + (item["count"].to_i > 1 ? " ×#{item['count']}" : "")
48
+ lines = [ "#{heading} #{item['kind'] == 'instruction' ? 'ℹ️' : '❗'} #{title}", "" ]
49
+
50
+ scope = [ "Scenarios: #{Array(item['scenario_keys']).map { |key| "`#{key}`" }.join(', ')}" ]
51
+ scope << "models: #{Array(item['models']).map { |label| "`#{label}`" }.join(', ')}" if Array(item["models"]).any?
52
+ scope << "judge suggestion" if item["kind"] == "instruction"
53
+ lines << scope.join(" · ") << ""
54
+ lines << item["recommendation"].to_s.strip << "" if item["recommendation"].present?
55
+ lines.concat(quoted(item["quote"]).push("")) if item["quote"].present?
56
+ tools = Array(item["tools"])
57
+ if tools.any?
58
+ lines << "**#{item['tools_label'].presence || 'tools'}:** " + tools.map { |tool| tool_text(tool) }.join(", ")
59
+ lines << ""
60
+ end
61
+ if (server = item["server"])
62
+ state = server["status"] == "enabled" ? "enabled for #{@agent_name}" : "#{server['status'].presence || 'unknown'} · not enabled for #{@agent_name}"
63
+ lines << "Served by **#{server['name'].presence || server['key']}** (#{state})" << ""
64
+ end
65
+ lines << "_#{item['note'].to_s.strip}_" << "" if item["note"].present?
66
+ if (action = item["action"])
67
+ lines << "Fix: #{[ action['label'], action['hint'] ].compact_blank.join(' — ')}" << ""
68
+ end
69
+ lines.join("\n").rstrip
70
+ end
71
+
72
+ # What to fix: every fix item, or with +model+ (a model label) the ones
73
+ # attributed to that model, as the report's model filter shows them.
74
+ def fix_items_markdown(model: nil, heading: "##")
75
+ items = fix_items
76
+ items = items.select { |item| Array(item["models"]).empty? || item["models"].include?(model) } if model.present?
77
+ lines = [ "#{heading} What to fix#{" for `#{model}`" if model.present?}", "" ]
78
+ if items.empty?
79
+ lines << "_Nothing to fix._"
80
+ else
81
+ sub = "#{heading}#"
82
+ lines << items.map { |item| fix_item_markdown(item, heading: sub) }.join("\n\n")
83
+ end
84
+ lines.join("\n")
85
+ end
86
+
87
+ # The verdict — the best model, why, and who picked it — then the
88
+ # per-model table and, comparing models, the comparison with its
89
+ # intervals.
90
+ def verdict_markdown(heading: "##")
91
+ lines = [ "#{heading} Verdict", "", judged_by_line, "" ]
92
+ lines.concat(verdict_lines)
93
+ lines.concat(summary_table)
94
+ lines << ""
95
+ lines.concat(total_lines)
96
+ lines.concat(comparison_lines.map { |line| line.start_with?("## ") ? line.sub("## ", "#{heading}# ") : line })
97
+ lines.join("\n").rstrip
98
+ end
99
+
100
+ # One scenario: its prompt and expectations, a table of each model's
101
+ # status, score, tool calls, fault and cost, then each model's answer.
102
+ # With +details+ it is a `<details>` block whose summary names the key,
103
+ # the prompt and each model's mark, the way the report folds it.
104
+ # Raises KeyError for a key the report has no result for.
105
+ def scenario_markdown(key, details: false, answer_limit: nil)
106
+ cohort = @results.select { |result| result.scenario.key == key.to_s }
107
+ raise KeyError, "no results for scenario #{key}" if cohort.empty?
108
+
109
+ scenario = cohort.first.scenario
110
+ labels = @models.map(&:label)
111
+ cohort = cohort.sort_by { |result| labels.index(result.label) || labels.size }
112
+ body = []
113
+ body << "**Prompt**" << "" << quoted(scenario.prompt).join("\n") << ""
114
+ expectations = expectation_lines(scenario)
115
+ body.concat(expectations).push("") if expectations.any?
116
+ body << "| Model | Status | Score | Tools | Fault | Cost |" << "|---|---|---|---|---|---|"
117
+ cohort.each do |result|
118
+ tools = result.replay.tool_calls.map { |call| "#{call['name']}#{' ✗' if call['error']}" }.join(", ")
119
+ body << "| #{[ "`#{result.label}`", status_text(result), (result.score ? Format.score(result.score) : '—'), tools.presence || '—',
120
+ result.fault&.tr('_', ' ') || '—', (result.replay.cost ? answer_cost(result) : '—') ].map { |text| cell(text) }.join(' | ')} |"
121
+ end
122
+ body << ""
123
+ cohort.each do |result|
124
+ body << "**`#{result.label}`** — #{status_text(result)}"
125
+ body << ""
126
+ body << "Fault: #{result.fault.tr('_', ' ')} — #{[ result.summary, result.recommendation ].compact.join(' ')}" << "" if result.fault
127
+ body << "Error: #{result.replay.error}" << "" if result.replay.error
128
+ answer = result.replay.answer.to_s
129
+ answer = answer.truncate(answer_limit) if answer_limit
130
+ body << (answer.present? ? quoted(answer).join("\n") : "_(no answer)_") << ""
131
+ end
132
+ text = body.join("\n").rstrip
133
+ return "### Scenario `#{scenario.key}`\n\n#{text}" unless details
134
+
135
+ marks = cohort.map { |result| "#{status_mark(result)} #{result.label}" }.join(" · ")
136
+ summary = "<code>#{html_text(scenario.key)}</code> #{html_text(scenario.prompt.truncate(80))} — #{html_text(marks)}"
137
+ "<details>\n<summary>#{summary}</summary>\n\n#{text}\n\n</details>"
138
+ end
139
+
140
+ private
141
+
142
+ def markdown_title
143
+ scenarios = scenario_keys.size
144
+ "Evaluation — #{scenarios} scenario#{'s' unless scenarios == 1} × #{@models.size} model#{'s' unless @models.size == 1}"
145
+ end
146
+
147
+ def scenario_keys
148
+ @results.map { |result| result.scenario.key }.uniq
149
+ end
150
+
151
+ def judged_by_line
152
+ label = @judge_label || @judge&.label
153
+ label ? "Judged by `#{label}`." : "No judge; scored on rules and expectations alone."
154
+ end
155
+
156
+ # "**Best model: gpt-5-mini**" with the verdict's rationale under it
157
+ # and, when a judge (not the pass-rate ranking) wrote it, who did.
158
+ def verdict_lines
159
+ return [] unless winner
160
+
161
+ lines = [ "**Best model: #{winner}**", "" ]
162
+ rationale = verdict["rationale"].to_s.strip
163
+ judge = verdict["judge"].presence
164
+ rationale += " _(#{judge == Report::PASS_RATE_JUDGE ? 'ranked by pass rate' : "judged by #{judge}"})_" if judge && rationale.present?
165
+ lines << rationale << "" if rationale.present?
166
+ lines
167
+ end
168
+
169
+ def expectation_lines(scenario)
170
+ lines = []
171
+ lines << "Expects tools: #{scenario.expected_tools.map { |name| "`#{name}`" }.join(' or ')}" if scenario.expected_tools.any?
172
+ lines << "Expects the answer to contain: #{scenario.expected_patterns.map { |text| "`#{text}`" }.join(', ')}" if scenario.expected_patterns.any?
173
+ lines << "Expects the answer not to contain: #{scenario.forbidden_patterns.map { |text| "`#{text}`" }.join(', ')}" if scenario.forbidden_patterns.any?
174
+ lines.map { |line| "- #{line}" }
175
+ end
176
+
177
+ def status_mark(result)
178
+ result.passed? ? "✅" : (result.errored? ? "⚠️" : "❌")
179
+ end
180
+
181
+ def status_text(result)
182
+ "#{status_mark(result)} #{result.status}"
183
+ end
184
+
185
+ def tool_text(tool)
186
+ tool = tool.is_a?(Hash) ? tool.stringify_keys : { "name" => tool.to_s }
187
+ note = tool["note"].presence
188
+ "`#{tool['name']}`#{" (#{note})" if note}"
189
+ end
190
+
191
+ # Each line of +text+ as a blockquote line, `<` and `>` escaped so an
192
+ # answer cannot open or close the HTML around it.
193
+ def quoted(text)
194
+ text.to_s.strip.split(/\r?\n/).map { |line| line.strip.empty? ? ">" : "> #{line.gsub('<', '&lt;').gsub('>', '&gt;')}" }
195
+ end
196
+
197
+ def html_text(text)
198
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub(/\s+/, " ")
199
+ end
200
+ end
201
+ end
202
+ end
@@ -27,6 +27,7 @@ require_relative "evals/judge"
27
27
  require_relative "evals/result"
28
28
  require_relative "evals/design_tokens"
29
29
  require_relative "evals/report_html"
30
+ require_relative "evals/report_markdown"
30
31
  require_relative "evals/report"
31
32
  require_relative "evals/runner"
32
33
  require_relative "evals/correlation"
@@ -132,6 +132,7 @@ module ActiveAgent
132
132
  :stream_broadcaster, :streaming, # Callback (Streams)
133
133
  :stream_completion_pending, # Callback (Streams)
134
134
  :stream_completion_result, # Callback (Streams)
135
+ :stream_finish_reason, # Callback (Streams)
135
136
  :tools_function, # Callback (Tools)
136
137
  :usage_stack, # Usage Tracking
137
138
  :stream_usage_index, # Usage Tracking (Streams)
@@ -607,6 +608,7 @@ module ActiveAgent
607
608
  messages:,
608
609
  raw_request: prompt_request_type.serialize(request),
609
610
  raw_response: api_response,
611
+ streamed_finish_reason: stream_finish_reason,
610
612
  usages: usage_stack,
611
613
  **attributes
612
614
  )
@@ -237,6 +237,7 @@ module ActiveAgent
237
237
  when :message_delta
238
238
  delta = Anthropic::Transforms.gem_to_hash(api_response_chunk.delta)
239
239
  self.message_stack.last.merge!(delta)
240
+ self.stream_finish_reason = delta[:stop_reason] if delta[:stop_reason]
240
241
 
241
242
  # Message Completed [Full Message]
242
243
  when :message_stop
@@ -63,6 +63,16 @@ module ActiveAgent
63
63
  # @return [Array<Usage>]
64
64
  attribute :usages, default: -> { [] }, writable: false
65
65
 
66
+ # @!attribute [r] streamed_finish_reason
67
+ # Why a streamed generation stopped, as the provider reported it.
68
+ #
69
+ # A streamed generation has no response body to read it from, so
70
+ # the provider records it from the stream. {#finish_reason} falls
71
+ # back to it.
72
+ #
73
+ # @return [String, nil]
74
+ attribute :streamed_finish_reason, writable: false
75
+
66
76
  # Initializes response with deep-duplicated attributes.
67
77
  #
68
78
  # Deep duplication prevents external modifications from affecting internal state.
@@ -180,14 +190,17 @@ module ActiveAgent
180
190
  # response.stop_reason #=> "stop" (alias)
181
191
  def finish_reason
182
192
  @finish_reason ||= begin
183
- return nil unless raw_response
193
+ return streamed_finish_reason unless raw_response
184
194
 
185
195
  if raw_response.is_a?(Hash)
186
196
  # OpenAI format: choices[0].finish_reason or choices[0].message.finish_reason
187
197
  raw_response.dig(:choices, 0, :finish_reason) ||
188
198
  raw_response.dig(:choices, 0, :message, :finish_reason) ||
189
199
  # Anthropic format: stop_reason
190
- raw_response[:stop_reason]
200
+ raw_response[:stop_reason] ||
201
+ # OpenAI Responses format: why it was cut short, else its status
202
+ raw_response.dig(:incomplete_details, :reason) ||
203
+ raw_response[:status]
191
204
  end
192
205
  end
193
206
  end
@@ -335,8 +335,10 @@ module ActiveAgent
335
335
  # Detect Ollama by presence of nanosecond duration fields
336
336
  if usage.key?(:total_duration)
337
337
  from_ollama(usage_hash)
338
- # Detect Anthropic by presence of cache_creation or service_tier
339
- elsif usage.key?(:cache_creation) || usage.key?(:service_tier)
338
+ # Detect Anthropic by presence of cache_creation or service_tier, or by its
339
+ # cache token fields (a usage payload does not always carry the former two)
340
+ elsif usage.key?(:cache_creation) || usage.key?(:service_tier) ||
341
+ usage.key?(:cache_read_input_tokens) || usage.key?(:cache_creation_input_tokens)
340
342
  from_anthropic(usage_hash)
341
343
  # Detect OpenAI Responses API by input_tokens/output_tokens with details
342
344
  elsif usage.key?(:input_tokens) && usage.key?(:input_tokens_details)
@@ -117,6 +117,10 @@ module ActiveAgent
117
117
  return if api_message.nil?
118
118
 
119
119
  # If we have a delta, we need to update a message in the stack
120
+ if (finish_reason = api_message.try(:finish_reason))
121
+ self.stream_finish_reason = finish_reason.to_s
122
+ end
123
+
120
124
  message = find_or_create_message(api_message.index)
121
125
  message_merge_delta(message, api_message.delta.deep_to_h)
122
126
 
@@ -263,6 +267,39 @@ module ActiveAgent
263
267
  message_stack.last
264
268
  end
265
269
 
270
+ # Finds the streamed tool call an unindexed delta continues
271
+ #
272
+ # OpenAI-compatible servers (Gemini's compatibility endpoint, some Ollama
273
+ # and OpenRouter backends) omit the `index` that ties argument fragments
274
+ # to their call. A delta carrying an `id` continues the call with that
275
+ # id; one without an id or a function name continues the last call;
276
+ # anything else starts a new call.
277
+ #
278
+ # @param tool_calls [Array<Hash>] calls accumulated so far
279
+ # @param delta_item [Hash] unindexed tool call delta
280
+ # @return [Hash, nil] the call to merge into, or nil for a new call
281
+ def unindexed_tool_call_target(tool_calls, delta_item)
282
+ if delta_item[:id].present?
283
+ tool_calls.find { _1[:id] == delta_item[:id] }
284
+ elsif delta_item.dig(:function, :name).blank?
285
+ tool_calls.last
286
+ end
287
+ end
288
+
289
+ # Strips the fields a server repeats on every fragment (`id`, `type`,
290
+ # the function name), so merging does not concatenate them.
291
+ #
292
+ # @param hash_item [Hash] the call the fragment continues
293
+ # @param delta_item [Hash] unindexed tool call delta
294
+ # @return [Hash] the fragment to merge
295
+ def unindexed_tool_call_fragment(hash_item, delta_item)
296
+ fragment = delta_item.except(:id, :type)
297
+ if fragment[:function].is_a?(Hash) && fragment.dig(:function, :name) == hash_item.dig(:function, :name)
298
+ fragment = fragment.merge(function: fragment[:function].except(:name))
299
+ end
300
+ fragment
301
+ end
302
+
266
303
  # Recursively merges delta changes into a hash structure
267
304
  #
268
305
  # Handles complex delta merging for OpenAI's streaming API, including
@@ -285,6 +322,8 @@ module ActiveAgent
285
322
  else
286
323
  hash[key] << delta_item
287
324
  end
325
+ elsif key == :tool_calls && delta_item.is_a?(Hash) && (hash_item = unindexed_tool_call_target(hash[key], delta_item))
326
+ hash_merge_delta(hash_item, unindexed_tool_call_fragment(hash_item, delta_item))
288
327
  else
289
328
  hash[key] << delta_item
290
329
  end
@@ -181,6 +181,9 @@ module ActiveAgent
181
181
  # usage from, so it is recorded from the final event.
182
182
  record_stream_usage(api_response_event.response.usage)
183
183
 
184
+ response = api_response_event.response
185
+ self.stream_finish_reason = (response.incomplete_details&.reason || response.status).to_s
186
+
184
187
  # Completion, tool loop included, runs from stream_finished! once
185
188
  # the stream has drained, so each turn's tool calls run once.
186
189
  self.stream_completion_pending = true
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/chat/_types"
4
+ require_relative "options"
5
+
6
+ module ActiveAgent
7
+ module Providers
8
+ module Opper
9
+ # ActiveModel type for casting and serializing Opper requests.
10
+ #
11
+ # Opper is OpenAI-compatible, so requests use the same shape as the
12
+ # OpenAI Chat API. This delegates entirely to OpenAI::Chat::RequestType.
13
+ RequestType = ActiveAgent::Providers::OpenAI::Chat::RequestType
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/options"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module Opper
8
+ # Configuration options for the Opper provider.
9
+ #
10
+ # Extends OpenAI::Options, overriding the base URL to point at Opper's
11
+ # OpenAI-compatible gateway and resolving the API key from OPPER_API_KEY.
12
+ # Opper does not use organization or project identifiers.
13
+ #
14
+ # @example Basic configuration
15
+ # options = Options.new(api_key: ENV["OPPER_API_KEY"])
16
+ #
17
+ # @see https://docs.opper.ai
18
+ # @see https://platform.opper.ai Opper API Keys
19
+ class Options < ActiveAgent::Providers::OpenAI::Options
20
+ # @!attribute base_url
21
+ # @return [String] API endpoint (default: "https://api.opper.ai/v3/compat")
22
+ attribute :base_url, :string, as: "https://api.opper.ai/v3/compat"
23
+
24
+ private
25
+
26
+ def resolve_api_key(kwargs)
27
+ kwargs[:api_key] ||
28
+ kwargs[:access_token] ||
29
+ ENV["OPPER_API_KEY"]
30
+ end
31
+
32
+ # Not used as part of Opper
33
+ def resolve_organization_id(kwargs) = nil
34
+ def resolve_project_id(kwargs) = nil
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,72 @@
1
+ require_relative "_base_provider"
2
+
3
+ require_gem!(:openai, __FILE__)
4
+
5
+ require_relative "open_ai_provider"
6
+ require_relative "opper/_types"
7
+
8
+ module ActiveAgent
9
+ module Providers
10
+ # Provides access to Opper's OpenAI-compatible LLM gateway.
11
+ #
12
+ # Extends the OpenAI provider to work with Opper's OpenAI-compatible API,
13
+ # enabling access to models from many providers through a single
14
+ # interface. Model ids are bare pool names (e.g. +claude-sonnet-4-6+);
15
+ # a +provider/model+ id (e.g. +anthropic/claude-sonnet-4-6+) pins a route.
16
+ #
17
+ # Opper is a plain OpenAI-compatible gateway: requests, responses and
18
+ # transforms are identical to the OpenAI Chat API, so this provider reuses
19
+ # OpenAI::Chat::RequestType and OpenAI::Chat::Transforms directly. The only
20
+ # Opper-specific configuration is the base URL and API key, which live in
21
+ # Opper::Options.
22
+ #
23
+ # @example Configuration in active_agent.yml
24
+ # opper:
25
+ # service: "Opper"
26
+ # api_key: <%= ENV["OPPER_API_KEY"] %>
27
+ # model: "claude-sonnet-4-6"
28
+ #
29
+ # @see OpenAI::ChatProvider
30
+ # @see https://docs.opper.ai
31
+ class OpperProvider < OpenAI::ChatProvider
32
+ # @return [String]
33
+ def self.service_name
34
+ "Opper"
35
+ end
36
+
37
+ # @return [Class]
38
+ def self.options_klass
39
+ Opper::Options
40
+ end
41
+
42
+ # @return [ActiveModel::Type::Value]
43
+ def self.prompt_request_type
44
+ OpenAI::Chat::RequestType.new
45
+ end
46
+
47
+ # @return [ActiveModel::Type::Value]
48
+ def self.embed_request_type
49
+ OpenAI::Embedding::RequestType.new
50
+ end
51
+
52
+ protected
53
+
54
+ # Executes an embedding request via Opper's OpenAI-compatible API.
55
+ #
56
+ # @param parameters [Hash]
57
+ # @return [Hash] symbolized API response
58
+ def api_embed_execute(parameters)
59
+ client.embeddings.create(**parameters).as_json.deep_symbolize_keys
60
+ end
61
+
62
+ # @see BaseProvider#api_response_normalize
63
+ # @param api_response [OpenAI::Models::ChatCompletion]
64
+ # @return [Hash] normalized response hash
65
+ def api_response_normalize(api_response)
66
+ return api_response unless api_response
67
+
68
+ OpenAI::Chat::Transforms.gem_to_hash(api_response)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.9.2"
2
+ VERSION = "1.9.3"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeagent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.2
4
+ version: 1.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-20 00:00:00.000000000 Z
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -467,6 +467,7 @@ files:
467
467
  - lib/active_agent/evals/replay.rb
468
468
  - lib/active_agent/evals/report.rb
469
469
  - lib/active_agent/evals/report_html.rb
470
+ - lib/active_agent/evals/report_markdown.rb
470
471
  - lib/active_agent/evals/result.rb
471
472
  - lib/active_agent/evals/ruby_llm.rb
472
473
  - lib/active_agent/evals/runner.rb
@@ -585,6 +586,9 @@ files:
585
586
  - lib/active_agent/providers/open_router_provider.rb
586
587
  - lib/active_agent/providers/openai_provider.rb
587
588
  - lib/active_agent/providers/openrouter_provider.rb
589
+ - lib/active_agent/providers/opper/_types.rb
590
+ - lib/active_agent/providers/opper/options.rb
591
+ - lib/active_agent/providers/opper_provider.rb
588
592
  - lib/active_agent/providers/requesty/_types.rb
589
593
  - lib/active_agent/providers/requesty/options.rb
590
594
  - lib/active_agent/providers/requesty_provider.rb