activeagent 1.9.1 → 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: c47ac88022fb9c9fe8919efaaf8cfb6824dfa7ba102cfc31df2bf42d8f4b19ef
4
- data.tar.gz: 9670044e2fd2fceae17068327a7f0b5e9695ae049f81cd2b00e7f56387c20286
3
+ metadata.gz: a7ecc64cad33188dd3cdfc563b553e49d0a05d42192698365a467061e6bb6d59
4
+ data.tar.gz: c3923cee8e401e17e3170c28f2fc83d8c290d70c5a4bc4d301951031d7d9cf43
5
5
  SHA512:
6
- metadata.gz: a2ee2907d4449caef1fd7f22d0b5418dfb19aa0140aaa02b47d7a394671dc16653d315ee80bcd6699a9baa0085c6202726ccbbefe9514689d1b0c53b708482e5
7
- data.tar.gz: f98937727b17855137cbf76434ebd5657dbd8d4c37a94119edafadd9da412e46ec44a068b4cbafbc19d04e2726dfa75e62f4eaad503e9db40709d9ca21aacc88
6
+ metadata.gz: 66e3244ce04d56625f880e26c18cbe3f353f35eca2f4dd5922a1adafb1d542ee5216065401e7ddbbe88cccc1c40336076ab6d4a30d54a5360846cad595ae9717
7
+ data.tar.gz: b023e5fe43f6674b7040086cc5a47c19fffc4bf3e919d0bb0ab0138d5c4839d3463f09e5a66d7047e4e28262628465a066b343ee76a503732b803ddc5d270bff
data/CHANGELOG.md CHANGED
@@ -5,7 +5,413 @@ 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
- ## [Unreleased]
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
+
210
+ ## [1.9.2] - 2026-10-20
211
+
212
+ Releases `activeagent` and `actionagent` 1.9.2, with the matching browser
213
+ sidecar: evaluations at scale (the scenario suite as a build page, model
214
+ comparison with intervals and P95 latency, opt-in parallel scenario runs,
215
+ the judge named after its model), provider streaming fixes (Anthropic
216
+ citations, OpenAI Chat refusals and logprobs, Responses built-in tools),
217
+ nested `has_many` schemas, and delegation contracts from `SchemaGenerator`
218
+ classes.
219
+
220
+ Upgrading: structured-output property names are now sent to the provider,
221
+ and answered, as declared. A `response_format` schema with multiword
222
+ property names (`first_name`) used to reach the provider camelized
223
+ (`firstName`) while `required` stayed snake_case, which strict mode
224
+ rejected; both are now as declared. `parsed_json` is unaffected; code that
225
+ reads `message.content` itself, or matches on request payloads, sees
226
+ `first_name` where it saw `firstName`. In a Rails app, `has_many` is
227
+ available on every `ActiveModel::Model` class, since the railtie includes
228
+ `SchemaGenerator` there, not only on classes that include it themselves.
229
+ Scenario fan-out is off by default: `config.scenario_evaluation_fan_out =
230
+ true` turns it on, and its jobs run on the `:agents` queue. No new
231
+ migration; a host still on 1.9.0 needs 1.9.1's migration 027.
232
+
233
+ ### Added
234
+
235
+ - **P50 and P95 latency per model in evaluation reports** (`activeagent`).
236
+ `Report#summary_by_model` adds `p50_duration_ms` and `p95_duration_ms`
237
+ beside `avg_duration_ms`, by nearest rank as the dashboard's Metrics view
238
+ computes them, and the Markdown and HTML reports show them next to the
239
+ mean, which hides the tail (#428).
240
+
241
+ - **Cost per 1,000 scenario runs in the Markdown report** (`activeagent`).
242
+ The summary table gains `Cost / 1,000`, each model's mean cost per priced
243
+ replay on a common denominator, marked `~` when it is estimated or
244
+ covers only some replays (#428).
245
+
246
+ - **`ActiveAgent::Evals::Comparison`: is the gap between models real?**
247
+ (`activeagent`). A 95% interval for each model's pass rate and for the
248
+ difference between every pair of models, from 5,000 seeded resamples of
249
+ whole scenarios (a paired bootstrap for the pairs, over the scenarios both
250
+ ran). A pair is decisive when its interval excludes zero, and
251
+ `#conclusion` says in one sentence which pairs are, or that the ranking
252
+ is not decisive evidence of superiority (#428).
253
+
254
+ - **The model comparison in evaluation reports** (`activeagent`).
255
+ `Report#comparison` and `to_h["comparison"]` carry it for a report that
256
+ compares models, the Markdown report gains a Comparison section, and
257
+ `Report.new(comparison:)` renders one already recorded for the results
258
+ instead of resampling, as `verdict:` does (#428).
259
+
260
+ - **A ranked interval plot in the HTML evaluation report** (`activeagent`).
261
+ Under the comparison table, each model's pass rate is a dot inside its
262
+ 95% interval on a fixed 0 to 100% axis, best first, with the figures
263
+ beside it, the conclusion, and how the intervals were computed. Plain
264
+ CSS on the design tokens: no script, light and dark (#428).
265
+
266
+ - **Each comparison run keeps its model comparison** (`actionagent`). A
267
+ scenario run that compares models records `Report#comparison` under
268
+ `scores["_comparison"]` when it completes; a published report's is
269
+ worked out from the stored results rather than taken from the payload;
270
+ and the run's report page renders the recorded intervals instead of
271
+ resampling on every view. A run recorded before gets one from its
272
+ results (#428).
273
+
274
+ - **P50 and P95 latency in the dashboard's model comparison**
275
+ (`actionagent`). The comparison table gains a P50 / P95 column and each
276
+ model's scorecard a p50 / p95 figure, from the percentiles the run
277
+ recorded; a run recorded before shows neither (#428).
278
+
279
+ - **The model comparison plotted in the dashboard** (`actionagent`). A
280
+ run that compared models shows, under its comparison table, each model's
281
+ pass rate inside its 95% interval, best first, with the run's
282
+ conclusion and how the intervals were computed, from the comparison the
283
+ run recorded (#428).
284
+
285
+ - Parallel scenario evaluations in `actionagent`: with
286
+ `config.scenario_evaluation_fan_out = true`, a run enqueues one
287
+ `EvaluationReplayJob` per scenario × model, each stored replay is scored by
288
+ its own `EvaluationJudgeJob`, and the last result to be scored completes the
289
+ run with the scores a sequential run writes. Runs as wide as the host's
290
+ `:agents` queue. Off by default; browser and host-adapter runs stay
291
+ sequential (#422).
292
+ - `ActiveAgent::Evals::Runner.new(refine_gate:)`, a callable asked before each
293
+ recommendation call so per-scenario runners share one run-wide judge limit.
294
+
295
+ - **Nested arrays of objects in ActiveModel schemas** (`activeagent`).
296
+ `has_many :addresses, class_name: "Address"` on an ActiveModel class that
297
+ includes `ActiveAgent::SchemaGenerator` makes `to_json_schema` emit the
298
+ attribute as an array of `$ref`s to the item class's schema, which goes
299
+ under the root `$defs`. `strict:` and `additional_properties:` reach the
300
+ nested schema, a nested class's own definitions move up to the root, and
301
+ a class that nests itself raises `ArgumentError`. A length validation on
302
+ the array sets its item count (`min_items` / `max_items`) rather than a
303
+ string's length. `has_many` also declares the attribute with an
304
+ empty-array default, and is added only to a class without one, so
305
+ ActiveRecord's is untouched (#260).
306
+
307
+ - **Evaluation runs record their judge** (`actionagent`), in
308
+ `scores["_judge"]`: the `EvaluationJudgeAgent` it runs as, its provider,
309
+ the model it asked for and where that setting came from (the evaluation,
310
+ the provider key, the host's config, or the provider's default), and the
311
+ models that answered, with call counts. The suite's Models panel shows
312
+ "judged by <model>" with an `auto` badge when no model is pinned, and
313
+ opens the judge's configuration, with a field that pins `judge_model` on
314
+ the evaluation through `PATCH /api/evaluations/:id` (#605).
315
+
316
+
317
+ ### Changed
318
+
319
+ - **A scenario suite's page reads like a build page** (`actionagent`). The
320
+ page opened on a summary, every run, the costs, the models and every
321
+ scenario stacked down the page, so on a suite of 100 scenarios and 12 runs
322
+ the selected run's results started three screens down. It now opens on the
323
+ selected run: a run header with the run's passed count, models, agent and
324
+ judge spend, a to-fix count and a progress bar; beside it a rail of runs,
325
+ each with a pass bar per model, and a box of the run's scenarios that
326
+ scrolls on its own, one line per step (`[+]` passed, `[!]` failed, `[~]`
327
+ running, `[ ]` queued, `[?]` errored, `[-]` skipped) under a sticky header
328
+ and foldable group rows, with a step's detail opening in place. A toolbar
329
+ filters the steps to failed only, puts failed first, shows the tool cells
330
+ and follows the running step until the person scrolls. Models, what to fix
331
+ and the footer follow below; a failed run shows its error in the header
332
+ instead of models. Selecting a run keeps the page where it is, polling
333
+ backs off and stops in a hidden tab, and below 940px the rail becomes a
334
+ strip of the latest runs. The same suite is now 2,786px tall where it was
335
+ 11,354px (#602).
336
+
337
+ ### Fixed
338
+
339
+ - **`max_retries` sets the Anthropic `json_object` retries** (`activeagent`).
340
+ `AnthropicProvider` read the count from its constructor's arguments after
341
+ `BaseProvider` had already moved `max_retries` into the provider's options,
342
+ so an unparseable `json_object` answer was always retried twice, whatever
343
+ `max_retries` said, on Anthropic and on Bedrock, which inherits it. The
344
+ count now comes from the options: `max_retries: 0` sends the request once,
345
+ `max_retries: 5` up to six times, and the default is still two retries
346
+ (#531).
347
+ - **A streamed Anthropic answer with citations no longer raises.** Claude
348
+ streams the citations of a text block as `citations_delta` deltas when it
349
+ cites a document or a web search result, and the Anthropic provider raised
350
+ `Unexpected delta type: citations_delta` on the first one. The delta is now
351
+ skipped like `input_json_delta`: the full block, citations included, arrives
352
+ at `content_block_stop`.
353
+ - **A streamed OpenAI Chat refusal, or a stream with logprobs, no longer
354
+ raises.** The openai gem's chat stream emits `refusal.delta` and
355
+ `refusal.done` when the model refuses, and `logprobs.*` events when the
356
+ request sets `logprobs`, and the Chat provider failed on them with
357
+ `Unexpected Response Event Type`. They are now handled, and `refusal.done`
358
+ ends the generation, since a refused message never gets a `content.done`.
359
+ - **A streamed Responses answer that used a built-in tool no longer raises.**
360
+ With `tools: [{ type: "web_search_preview" }]` (or file search, image
361
+ generation, code interpreter, remote MCP), the stream carries the tool's
362
+ call as an output item (`web_search_call`, ...), its progress events
363
+ (`response.web_search_call.searching`, ...), and
364
+ `response.output_text.annotation.added` for the citations, and the
365
+ Responses provider raised `Unexpected Item Type: web_search_call` on the
366
+ first of them. The tool calls are now kept like in a non-streamed response,
367
+ and their events are skipped.
368
+ - **A streamed Responses answer cut short no longer raises.** When a response
369
+ hits `max_output_tokens`, the stream ends with `response.incomplete` instead
370
+ of `response.completed`, and the Responses provider raised `Unexpected
371
+ Response Chunk Type: response.incomplete`. It now ends the generation with
372
+ the text streamed so far, like `response.completed`.
373
+ - **A streamed Responses refusal no longer raises.** The provider raised
374
+ `Unexpected Response Chunk Type: response.refusal.delta` when the model
375
+ refused; the refusal events are now skipped and the response completes.
376
+ - **A streamed Responses generation reports its token usage.** The usage of a
377
+ streamed response is only on its final event, and the Responses provider
378
+ never read it, so `response.usage` was `nil`. It is now recorded from
379
+ `response.completed` or `response.incomplete`, as the Chat provider does
380
+ from its usage chunk.
381
+ - **Delegation contracts built from a `SchemaGenerator` class**
382
+ (`activeagent`). `delegation ..., schema: TicketForm` and
383
+ `returns TicketForm` produced a contract with no properties, because
384
+ `to_json_schema` wraps the schema in `{ name:, schema:, strict: }` and
385
+ the contract read only a top-level `properties`. The wrapper is now
386
+ unwrapped, and `$defs` are kept so the `$ref`s that nested schemas use
387
+ still resolve. A contract built from a class is sent with the strict
388
+ setting the class declared: `Model.to_json_schema` is not strict unless
389
+ asked, and its `required` then names only the presence-validated
390
+ attributes, which OpenAI's strict mode refuses, so `returns Model` goes
391
+ out with `strict: false` and `returns Model.to_json_schema(strict: true)`
392
+ strict with every attribute required.
393
+
394
+ - **Structured-output schema normalization preserves field names**
395
+ (`activeagent`). `prompt` converts JSON Schema keywords to camel case
396
+ without renaming properties or definitions, keeping multiword attributes
397
+ and `has_many` associations consistent with `required` and `$ref` in
398
+ strict schemas. Literal data in `default`, `const`, `enum` and `examples`
399
+ is preserved too (#260).
400
+
401
+ Property names now reach the provider as declared, so a `first_name`
402
+ attribute is sent as `first_name` rather than `firstName`, and the model
403
+ answers with that key. `parsed_json` underscores keys either way, so code
404
+ reading it is unaffected; code reading the raw `message.content`, or
405
+ matching on request payloads, sees snake_case where it saw camelCase.
406
+
407
+ A delegation's `returns` schema follows suit: its `required` entries are
408
+ no longer camelized, so a multiword required key such as `max_points`
409
+ matches its property in strict mode instead of being sent as
410
+ `maxPoints`.
411
+ - **A judge with no `judge_model` is labelled with the model that
412
+ answered** (`actionagent`), not its provider ("judged by openrouter").
413
+ OpenRouter's default, `openrouter/auto`, routes each call, so a run judged
414
+ by more than one model is labelled "<provider> default" (#605).
9
415
 
10
416
  ## [1.9.1] - 2026-10-16
11
417
 
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
 
@@ -397,13 +397,38 @@ module ActiveAgent
397
397
 
398
398
  if response_format[:type].to_sym == :json_schema
399
399
  response_format = nest_flat_json_schema(response_format)
400
- response_format[:json_schema] = prompt_view_schema(response_format[:json_schema])
401
- response_format[:json_schema].deep_transform_keys! { _1.to_s.camelize(:lower) }
400
+ response_format[:json_schema] = normalize_json_schema(prompt_view_schema(response_format[:json_schema]))
402
401
  end
403
402
 
404
403
  response_format
405
404
  end
406
405
 
406
+ # Convert Ruby-style schema keywords without changing property names,
407
+ # definition names, references or literal data (default, const, enum,
408
+ # examples). Only schema-valued keywords are traversed. Named maps keep
409
+ # their keys, so required entries and $ref paths still address them.
410
+ #
411
+ # @api private
412
+ def normalize_json_schema(schema)
413
+ return schema.map { |item| normalize_json_schema(item) } if schema.is_a?(Array)
414
+ return schema unless schema.is_a?(Hash)
415
+
416
+ schema.to_h do |key, value|
417
+ keyword = key.to_s.camelize(:lower)
418
+ normalized = case keyword
419
+ when "properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"
420
+ value.is_a?(Hash) ? value.transform_values { |definition| normalize_json_schema(definition) } : value
421
+ when "schema", "items", "prefixItems", "additionalItems", "contains", "additionalProperties",
422
+ "unevaluatedItems", "unevaluatedProperties", "propertyNames", "contentSchema",
423
+ "allOf", "anyOf", "oneOf", "not", "if", "then", "else"
424
+ normalize_json_schema(value)
425
+ else
426
+ value
427
+ end
428
+ [ keyword, normalized ]
429
+ end
430
+ end
431
+
407
432
  # Moves a json_schema given flat, with its name, schema and strict flag
408
433
  # beside +type+, under +json_schema+, so it is not mistaken for a
409
434
  # json_schema that names a view. Without a top-level schema, or with a
@@ -56,15 +56,25 @@ module ActiveAgent
56
56
  schema
57
57
  end
58
58
 
59
- # @param hash [Hash] JSON Schema object
59
+ # @param hash [Hash] JSON Schema object, or the +{ name:, schema:, strict: }+
60
+ # wrapper {ActiveAgent::SchemaGenerator} returns around one
60
61
  # @return [Schema]
61
62
  def from_hash(hash)
62
63
  hash = hash.deep_symbolize_keys
64
+ strict = nil
65
+ if !hash.key?(:properties) && hash[:schema].is_a?(Hash)
66
+ # SchemaGenerator's wrapper carries +strict: true+ only when the
67
+ # schema was built for strict mode; otherwise it is not strict.
68
+ strict = hash[:strict] == true
69
+ hash = hash[:schema]
70
+ end
63
71
 
64
72
  new.tap do |schema|
65
73
  (hash[:properties] || {}).each { |name, definition| schema.property(name, definition) }
66
74
  schema.required(*Array(hash[:required]))
67
75
  schema.additional_properties(hash.fetch(:additionalProperties, hash.fetch(:additional_properties, false)))
76
+ schema.definitions(hash[:$defs]) if hash[:$defs]
77
+ schema.strict(strict) unless strict.nil?
68
78
  end
69
79
  end
70
80
  end
@@ -73,6 +83,8 @@ module ActiveAgent
73
83
  @properties = {}
74
84
  @required = []
75
85
  @additional_properties = false
86
+ @definitions = {}
87
+ @strict = nil
76
88
  end
77
89
 
78
90
  # @return [Boolean] true when nothing has been declared
@@ -158,6 +170,29 @@ module ActiveAgent
158
170
  @additional_properties = value
159
171
  end
160
172
 
173
+ # Declares whether the schema was built for a provider's strict mode,
174
+ # as the +strict:+ of the wrapper {ActiveAgent::SchemaGenerator} returns
175
+ # says. Strict mode wants every property in +required+; a
176
+ # SchemaGenerator schema names only the attributes a presence
177
+ # validation covers unless it was built with +strict: true+, so a
178
+ # class is sent as it declared itself (see #to_response_format).
179
+ #
180
+ # @param value [Boolean]
181
+ # @return [void]
182
+ def strict(value = true)
183
+ @strict = value
184
+ end
185
+
186
+ # Declares the subschemas that properties reference with
187
+ # +"$ref": "#/$defs/name"+, such as the nested schemas
188
+ # {ActiveAgent::SchemaGenerator} emits for associations.
189
+ #
190
+ # @param definitions [Hash{Symbol => Hash}]
191
+ # @return [void]
192
+ def definitions(definitions)
193
+ @definitions = @definitions.merge(definitions.deep_symbolize_keys)
194
+ end
195
+
161
196
  # @return [Array<Symbol>] declared property names
162
197
  def keys
163
198
  @properties.keys
@@ -177,27 +212,30 @@ module ActiveAgent
177
212
  type: "object",
178
213
  properties: @properties.deep_dup,
179
214
  required: @required.map(&:to_s),
180
- additionalProperties: @additional_properties
181
- }
215
+ additionalProperties: @additional_properties,
216
+ "$defs": (@definitions.deep_dup if @definitions.any?)
217
+ }.compact
182
218
  end
183
219
  alias_method :to_h, :to_json_schema
184
220
 
185
221
  # JSON Schema for a +response_format+ payload.
186
222
  #
187
- # ActiveAgent camelizes response-format schema *keys* on the way to the
188
- # provider, but the +required+ array holds string *values* — so they are
189
- # camelized here to keep the emitted schema internally consistent.
190
- # {Assistant#parsed_json} underscores the keys again on the way back, so
191
- # agent code only ever sees snake_case.
223
+ # ActiveAgent sends property names to the provider as declared, so the
224
+ # +required+ array names them as declared too.
225
+ # {Assistant#parsed_json} underscores the keys of the answer, so agent
226
+ # code only ever sees snake_case.
192
227
  #
193
228
  # @param name [String] schema name reported to the provider
194
- # @param strict [Boolean]
229
+ # @param strict [Boolean, nil] nil follows the source: what a
230
+ # SchemaGenerator class declared (not strict unless built with
231
+ # +strict: true+, since its +required+ otherwise lists only the
232
+ # presence-validated attributes, which strict mode refuses), else true
195
233
  # @return [Hash]
196
- def to_response_format(name:, strict: true)
197
- schema = to_json_schema
198
- schema[:required] = schema[:required].map { |key| key.camelize(:lower) }
234
+ def to_response_format(name:, strict: nil)
235
+ strict = @strict if strict.nil?
236
+ strict = true if strict.nil?
199
237
 
200
- { name: name, schema: schema, strict: strict }
238
+ { name: name, schema: to_json_schema, strict: strict }
201
239
  end
202
240
 
203
241
  # Validates a parsed payload against the declared required properties.