activeagent 1.9.7 → 1.9.8

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: a2e216123a2bdec63446cf6f6af685c0c61b285a318f5a98845e09b188838427
4
- data.tar.gz: 434fbcc22e9a7ffa10434c93ba4b3d63306e6f0ae5502742eb2c83a31abb9338
3
+ metadata.gz: 560139e6515a8b3568aabc383ec0ae4219764a2d8ca91b14b4defc4fdec3439d
4
+ data.tar.gz: b1d8651cb0a058b8922e579374720efd42c3babcadfddc3c123c5b7c6c2afd8b
5
5
  SHA512:
6
- metadata.gz: 442971fa937ce0d4f44c6add3d3e8841f48789da87de99363e8fcc0a891bcf4bc49461b914c5816ee2754a8c8b22ed92d42b0a26cff3423f3c5de8fbdd3e653c
7
- data.tar.gz: 971714f2043a08e213f3a646706da229d77f289f2a98e7674169406ee1b5767235b98988a71113868dcd93d680da72dd9721859d49c30577f812ac902e84704f
6
+ metadata.gz: 722dcbfe0e8bc2254871264f42ddf5394eb4f26acfc47f53ce17f2c9b382db627248d90ded1c9164a30bd176f908ee4510db11332e26bd6e25c91d485b251fde
7
+ data.tar.gz: e97465f43ad9dfee0eecfa0e3b3207b7c3a67dc620a1fa656c451800b9e49ca89259a75dd7ce7ad7066f05bfeece5d719fdf869e30a3cf89ae11f6e6f9de42dd
data/CHANGELOG.md CHANGED
@@ -5,7 +5,183 @@ 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.8] - 2026-11-12
9
+
10
+ The dashboard holds a run to its catalog's pass bars: a product's and a
11
+ set's `pass` blocks and a product's named criteria lists survive import, a
12
+ run records the bars it started under and shows each group's gate beside
13
+ its pass rate, and `evals run` exits 4 when a gated group misses, so CI
14
+ fails on the group that slipped rather than on the whole-run average. A
15
+ catalog that declares a `threshold` now scores at it instead of the fixed
16
+ 0.7. Around that: the empty agent list shows the ways an agent arrives,
17
+ telemetry first; the RubyLLM provider works with ruby_llm 2.2 and the
18
+ Anthropic provider with the `anthropic` gem 2.x; Runcrate joins the
19
+ OpenAI-compatible providers; and smaller fixes to Anthropic usage counts,
20
+ Requesty embeddings, `top_k` for thinking-first models, late cancels,
21
+ `tool_observer` under Capture and Responses streaming errors.
22
+
23
+ Upgrading: two dashboard migrations, 034 `add_catalog_product_criteria`
24
+ (the `criteria` column on scenario products) and 035
25
+ `add_catalog_pass_bars` (the `pass` column on scenario products and sets):
26
+ run `bin/rails generate action_agent:install --skip` and `bin/rails
27
+ db:migrate`. Until they run, catalog imports keep working but still drop
28
+ named criteria lists and pass bars, so dashboard runs stay ungated; a
29
+ catalog imported before the upgrade needs importing again to gain them.
30
+ 033 is left free for a later release. A finished run that carries gates
31
+ now fails `evals run --wait` with exit 4 when any gated group missed its
32
+ bar; a run without gates is reported as before. ruby_llm 2.2.0 (2026-11-10)
33
+ breaks the RubyLLM provider in 1.9.7 and earlier (every tool call reads as
34
+ `nil`); upgrade, or hold `ruby_llm < 2.2` until you do.
35
+
36
+ ### Added
37
+
38
+ - **The dashboard shows a run's per-group pass gates** (`actionagent`). A
39
+ published report's `pass_thresholds` are kept with the run, so the
40
+ rebuilt report, the export and the run page gate the stored results as
41
+ the application's report did, and a run started from the dashboard on a
42
+ catalog set is held to the set's and product's `pass` bars, recorded as
43
+ the run starts so a later catalog change does not regrade it. The run
44
+ page adds a **Pass rate by group** table, and the run's badge reads
45
+ `gate missed` when a gate was not met even if the whole-run pass rate
46
+ looks fine. The run's JSON adds `gates` and `gates_met`; a run without
47
+ bars reads as before (#678).
48
+ - **An empty agent list shows how agents arrive** (`actionagent`). With no
49
+ agents, the Agents page offered only Browse Templates and Create From
50
+ Scratch. It now shows three ways in, telemetry first:
51
+ - **Connect your app** asks what runs the agents. For ActiveAgent it shows
52
+ the `config/active_agent.yml` telemetry block. For RubyLLM it shows the
53
+ `activeagents-telemetry-ruby_llm` Gemfile line and the initializer that
54
+ subscribes it. Both point at this dashboard's endpoint and read the key
55
+ from `ACTIVEAGENTS_API_KEY`. The first trace registers the agent, and
56
+ **Check for agents** reloads the list.
57
+ - **Link a GitHub repository** opens New project.
58
+ - **Build one here** keeps the template library and the builder.
59
+ - **Runcrate as an OpenAI-compatible provider** (`activeagent`).
60
+ `service: "Runcrate"` generates and embeds through
61
+ `https://api.runcrate.ai/v1` (open-weight models such as
62
+ `deepseek-ai/DeepSeek-V4.1-Flash` and `Qwen/Qwen3-Embedding-8B`), with the
63
+ key from `RUNCRATE_API_KEY`; a thin subclass of the OpenAI provider, like
64
+ Cheaper Inference.
65
+
66
+ ### Fixed
67
+
68
+ - **The RubyLLM provider works with ruby_llm 2.2** (`activeagent`). RubyLLM
69
+ 2.2.0 (2026-11-10) changed three things the provider leaned on. A
70
+ message's `tool_calls` became a `ToolCall::Collection` that yields the
71
+ calls rather than ID-and-call pairs, so every tool call read as `nil`; a
72
+ streamed delta now carries the provider's call ID in `provider_call_id`
73
+ and leaves `id` empty, so every fragment joined the latest call and
74
+ arguments doubled up; and a chat asks each tool for its `namespace`,
75
+ `qualified_name` and `deferred?`, which ActiveAgent's tool proxy did not
76
+ answer. The provider now iterates either shape, keys streamed fragments
77
+ by the provider's ID, and the proxy answers the three. ruby_llm 1.16
78
+ through 2.1 behave as before. The evals conversation check also learned
79
+ 2.2's `acts_as_chat` modules, columns and reworded unavailable-tool error.
80
+ - **`evals run` fails CI on a missed group gate** (`activeagent`). A run
81
+ whose critical group slipped to 50% passed `evals run --min-pass-rate`
82
+ whenever the other groups lifted the whole-run pass rate over the bar.
83
+ A finished run that the dashboard holds to per-group pass gates now fails
84
+ (exit 4) when any gated group missed its bar, with `--wait` alone or with
85
+ `--min-pass-rate`, which stays the whole-run bar; each missed gate prints
86
+ as one line, and `--json` adds `gates`. A run without gates is gated as
87
+ before (#677).
88
+ - **Responses streaming failures retain their error details** (`activeagent`).
89
+ `error` and `response.failed` events raise a provider error with the API
90
+ code and message instead of an unexpected chunk type error.
91
+ - **The dashboard keeps a catalog's pass bars** (`actionagent`). A
92
+ product's and a set's `pass` blocks (`min_pass_rate`, `threshold`,
93
+ `gated`) parsed in `ActiveAgent::Evals::Catalog`, but neither
94
+ `ScenarioProduct` nor `ScenarioSet` kept them: an import dropped the
95
+ bars, the export left them out, the stored catalog's `pass_thresholds`
96
+ fell back to the defaults (every group gated at 100%), and the import
97
+ preview reported the product and its sets as `update` on every re-import
98
+ of an unchanged file. The bars are now stored and exported as imported,
99
+ and both import modes take the document's. Existing installs add the
100
+ columns with `bin/rails generate action_agent:install --skip` and
101
+ `bin/rails db:migrate` (`add_catalog_pass_bars`); imports keep working,
102
+ without the bars, until it runs. Bars dropped by an earlier version come
103
+ back when the original document is imported again.
104
+ - **The dashboard keeps a catalog product's named criteria lists**
105
+ (`actionagent`). A product's `criteria: { shared: [...], strict: [...] }`
106
+ parsed and round-tripped in `ActiveAgent::Evals::Catalog`, but
107
+ `ScenarioProduct` had nowhere to keep it: an import dropped the lists, the
108
+ export left them out, the stored digest never matched the records'
109
+ export, and the import preview reported the product as `update` on every
110
+ re-import of an unchanged file. The lists are now stored on the product
111
+ and exported as imported, so the documents and digests match and an
112
+ unchanged file previews as unchanged. Both import modes take the
113
+ document's lists, as they take a set's `criteria`. A set still runs on
114
+ its own `criteria`; which product lists a run uses is left for later.
115
+ Existing installs add the column with
116
+ `bin/rails generate action_agent:install --skip` and `bin/rails db:migrate`
117
+ (`add_catalog_product_criteria`); imports keep working, without the lists,
118
+ until it runs. Lists dropped by an earlier version come back when the
119
+ original document is imported again (#687).
120
+ - **A self-hosted dashboard shows its own trace endpoint** (`actionagent`).
121
+ The Organization page's Telemetry card showed the platform's `/v1/traces`.
122
+ A self-hosted engine takes traces at `<mount>/api/traces`, and the card
123
+ now shows that. Multi-tenant installs are unchanged.
124
+ - **The agent list's filter arrows sit inside the select** (`actionagent`).
125
+ The browser's own arrow ignores padding and sat against the right border.
126
+ The three filters now draw an inset chevron (`aa-select`).
127
+ - **The Anthropic provider loads the `anthropic` gem 2.x** (`activeagent`).
128
+ The provider pinned its client gem to `~> 1.12`, so a bundle holding
129
+ anthropic 2.0 or later raised `LoadError` the moment the provider loaded,
130
+ although the provider builds its requests as hashes and the 2.0 SDK
131
+ (role-union `MessageParam`, one-value enums as constants, Text Completions
132
+ removed) breaks none of them. The provider and the gemspec's development
133
+ dependency now accept anthropic 1.12 through 2.x, and CI runs the provider
134
+ tests against 2.3 beside the 1.12, 1.14 and 1.55 legs. Two 2.0 changes do
135
+ reach the code: Text Completions went away with
136
+ `Anthropic::Resources::Completions`, which the Bedrock bearer client now
137
+ sets up only when it exists, and the client sends its key as
138
+ `Authorization: Bearer` instead of `x-api-key`. The 2.0 SDK also dropped
139
+ the `?beta=true` query string from its beta Messages requests, so the test
140
+ suite matches recorded cassettes with or without it.
141
+ - **A streamed Anthropic response reports its token usage**
142
+ (`activeagent`). With `stream: true`, `response.usage` was nil, so a
143
+ streamed generation counted 0 tokens and cost nothing. The provider now
144
+ records the usage `message_start` reports and the cumulative counts
145
+ `message_delta` carries, as the OpenAI providers do from their chunks.
146
+ - **Anthropic's `total_tokens` counts cached tokens** (`activeagent`).
147
+ Anthropic's `input_tokens` leaves out cache reads and writes, which it
148
+ reports separately, so `total_tokens` undercounted any prompt that used
149
+ the cache. It is now input + output + cache read + cache creation. The
150
+ other counts are unchanged, and so is cost: pricing still reads
151
+ `input_tokens` and `output_tokens` only.
152
+ - **Added usage keeps its duration** (`activeagent`). `Usage#+` dropped
153
+ `duration_ms`, so the usage of a tool-calling loop (Ollama reports a
154
+ duration) came back without one. It is now summed like the other
155
+ optional counts.
156
+ - **Requesty embeddings work** (`activeagent`). The Requesty provider
157
+ declared an embedding request but never sent one, so `embed` raised
158
+ `NotImplementedError`. It now calls Requesty's OpenAI-compatible
159
+ `/v1/embeddings`.
160
+ - **`top_k` is stripped for thinking-first models** (`activeagent`).
161
+ `ModelCapabilities` removed `temperature` and `top_p` for the models that
162
+ reject sampling parameters, but not `top_k`, which they reject too, so an
163
+ agent configured with it still got a 400.
164
+ - **A late cancel leaves a finished run as it finished** (`actionagent`).
165
+ `AgentRun#cancel!` checked the status of its own copy of the run and
166
+ then wrote `cancelled` unconditionally. A cancel request that loaded the
167
+ run while it was running, and wrote after the worker had recorded its
168
+ result, turned a complete run into a cancelled one that still carried
169
+ its output, and replaced a failed run's error with "Cancelled by user".
170
+ The cancel now moves the run to `cancelled` in one conditional update,
171
+ as the execution job claims a pending run, and only from pending,
172
+ running or awaiting input. A run that finished first keeps its status,
173
+ output, error and completion time; `cancel!` returns false and reloads
174
+ the copy, so the API answers with the status the run ended in. A cancel
175
+ is broadcast once, where the callback and the method each sent it
176
+ before. Cancelling still does not stop a provider call already in
177
+ flight; it keeps that call's result from being recorded.
178
+ - **Capture keeps an agent's own `tool_observer`** (`activeagent`). Since
179
+ 1.9.7, `Evals::Capture` records client-side MCP calls through the
180
+ agent's `tool_observer`, and it replaced an observer the agent defined
181
+ itself, so that observer stopped running whenever the agent was
182
+ captured. Capture now runs the agent's observer around the call it
183
+ records: the server is still called once, both see the same result or
184
+ exception, and a call the observer withholds is not recorded as made.
9
185
 
10
186
  ## [1.9.7] - 2026-11-04
11
187
 
data/README.md CHANGED
@@ -66,6 +66,9 @@ bundle add openai
66
66
  # Cheaper Inference (uses OpenAI-compatible API)
67
67
  bundle add openai
68
68
 
69
+ # Runcrate (uses OpenAI-compatible API)
70
+ bundle add openai
71
+
69
72
  # DeepSeek (uses OpenAI-compatible API)
70
73
  bundle add openai
71
74
 
@@ -86,8 +86,8 @@ module ActiveAgent
86
86
  p.on("--keys LIST", "Replay only these scenario keys, comma-separated") { |v| arguments[:keys] = list_option(v) }
87
87
  p.on("--group GROUP", "Replay only this scenario group") { |v| arguments[:group] = v }
88
88
  p.on("--sandbox ID", "Replay against this ready checkout sandbox") { |v| arguments[:sandbox_id] = v }
89
- p.on("--wait", "Wait for the run to finish") { wait = true }
90
- p.on("--min-pass-rate RATE", "Fail (exit #{EXIT_GATE}) below this pass rate: 0.8 or 80%; implies --wait") do |v|
89
+ p.on("--wait", "Wait for the run to finish; fail (exit #{EXIT_GATE}) when it failed or missed a pass gate") { wait = true }
90
+ p.on("--min-pass-rate RATE", "Also fail below this whole-run pass rate: 0.8 or 80%; implies --wait") do |v|
91
91
  min_pass_rate = pass_rate_option(v)
92
92
  wait = true
93
93
  end
@@ -199,18 +199,28 @@ module ActiveAgent
199
199
  end
200
200
  end
201
201
 
202
+ # A finished run fails the gate when it failed, when its whole pass
203
+ # rate is under --min-pass-rate, or when it was held to per-group pass
204
+ # gates (the run's "gates", from the suite's or catalog set's `pass`
205
+ # bars) and the dashboard says it did not meet every one. A run
206
+ # without gates is gated on its status and rate alone, as it was.
202
207
  def gate(payload, min_pass_rate)
203
208
  run = payload.fetch("run", {})
204
209
  rate = pass_rate(run)
205
210
  failed = run["status"] == "failed"
206
211
  below = min_pass_rate && (rate.nil? || rate < min_pass_rate)
207
- verdict = failed || below ? "fail" : "pass"
212
+ gates = run_gates(run)
213
+ missed = !gates.nil? && run["gates_met"] != true
214
+ verdict = failed || below || missed ? "fail" : "pass"
208
215
  failures = payload.fetch("results", [])
216
+ summary = { "pass_rate" => rate, "min_pass_rate" => min_pass_rate }
217
+ summary["gates"] = gates if gates
209
218
 
210
- emit(payload.merge("pass_rate" => rate, "min_pass_rate" => min_pass_rate, "verdict" => verdict)) do
219
+ emit(payload.merge(summary).merge("verdict" => verdict)) do
211
220
  @out.puts "Run #{run["id"]} #{run["status"]}: #{passed(run) || "no results"}" \
212
221
  "#{rate ? format(" (%.1f%%)", rate * 100) : ""}#{min_pass_rate ? format(", threshold %.1f%%", min_pass_rate * 100) : ""}."
213
222
  @out.puts "Error: #{run["error_message"]}" if run["error_message"]
223
+ print_gates(gates, missed) if gates
214
224
  unless failures.empty?
215
225
  @out.puts "Failed results:"
216
226
  table(failures, {
@@ -227,6 +237,39 @@ module ActiveAgent
227
237
  verdict == "pass" ? EXIT_SUCCESS : EXIT_GATE
228
238
  end
229
239
 
240
+ # The run's per-group pass gates as the dashboard worked them out
241
+ # (EvaluationRun#gates), or nil for a run held to none.
242
+ def run_gates(run)
243
+ rows = run["gates"]
244
+ rows.select { |row| row.is_a?(Hash) } if rows.is_a?(Array)
245
+ end
246
+
247
+ def print_gates(gates, missed)
248
+ unmet = gates.reject { |gate| gate["met"] == true }
249
+ unless missed
250
+ @out.puts "Gates: every gate met (#{gates.size})."
251
+ return
252
+ end
253
+
254
+ @out.puts(unmet.empty? ? "Gates: no gate was reached." : "Gates not met:")
255
+ unmet.each { |gate| @out.puts " #{gate_sentence(gate)}" }
256
+ end
257
+
258
+ # A gate the run missed, as ActiveAgent::Evals::Report::Gate#to_s
259
+ # reads it: "Orders · gpt-5-mini: 1/2 (50%) passed, below 100%".
260
+ def gate_sentence(gate)
261
+ passed = gate["passed"].to_i
262
+ total = gate["total"].to_i
263
+ passes = total.positive? ? "#{passed}/#{total} (#{whole_percent(passed.to_f / total)})" : "no results"
264
+ label = cell(gate["label"]) unless gate["label"].to_s.empty?
265
+ "#{cell(gate["name"] || gate["group"] || "All")}#{label ? " · #{label}" : ""}: #{passes} passed, below #{whole_percent(gate["min_pass_rate"])}"
266
+ end
267
+
268
+ def whole_percent(fraction)
269
+ value = Float(fraction, exception: false)
270
+ value ? "#{(value * 100).round}%" : "?"
271
+ end
272
+
230
273
  def pass_rate(run)
231
274
  evaluated = run["samples_evaluated"].to_i
232
275
  evaluated.positive? ? run["samples_passed"].to_f / evaluated : nil
@@ -30,8 +30,10 @@ module ActiveAgent
30
30
  # A callback the provider runs the calls it serves from an MCP server
31
31
  # around, which never reach {#tools_function}. It receives the tool name,
32
32
  # its arguments and a block that makes the call, and returns the block's
33
- # result. Nil unless a host that records a generation's tool calls (see
34
- # Evals::Capture) sets it on the instance.
33
+ # result. Nil unless the agent defines one, to audit its MCP calls say,
34
+ # or a host that records a generation's tool calls sets it on the
35
+ # instance. Evals::Capture keeps an agent's own observer and records the
36
+ # call that observer makes.
35
37
  #
36
38
  # @return [Proc, nil]
37
39
  def tool_observer = nil
@@ -19,8 +19,10 @@ module ActiveAgent
19
19
  # (`mcp_strategy: :client`, or a provider with no MCP of its own) is
20
20
  # dispatched before that proc, so the provider reports it through the
21
21
  # agent's `tool_observer` instead, with a failure the server reported
22
- # marked as one. A tool a provider runs on its own side (Anthropic's or
23
- # OpenAI's hosted MCP) never reaches this process and is not recorded.
22
+ # marked as one. An observer the agent defines itself still runs, around
23
+ # the call that is recorded. A tool a provider runs on its own side
24
+ # (Anthropic's or OpenAI's hosted MCP) never reaches this process and is
25
+ # not recorded.
24
26
  #
25
27
  # `model:` and `provider:` replay the same action under another model,
26
28
  # the way a comparison run does. A model is an option on the agent
@@ -190,9 +192,15 @@ module ActiveAgent
190
192
  # the provider runs that call inside the agent's `tool_observer`, which
191
193
  # records it the same way. The two paths are disjoint, so a call is
192
194
  # recorded once, and the observer sees the call without making it again.
195
+ #
196
+ # An observer the agent already has is kept, as tools_function's `base`
197
+ # is: it runs around the call this records, so it sees the same name,
198
+ # arguments, result and exception, and a call it does not make is not
199
+ # recorded as made.
193
200
  def record_tool_calls(agent)
194
201
  calls = @tool_calls
195
202
  base = agent.tools_function
203
+ observer = agent.tool_observer
196
204
  capture = self.class
197
205
 
198
206
  agent.define_singleton_method(:tools_function) do
@@ -203,7 +211,10 @@ module ActiveAgent
203
211
  end
204
212
 
205
213
  agent.define_singleton_method(:tool_observer) do
206
- proc { |name, arguments, &call| capture.record_call(calls, name, arguments.presence, &call) }
214
+ record = proc { |name, arguments, &call| capture.record_call(calls, name, arguments.presence, &call) }
215
+ next record unless observer
216
+
217
+ proc { |name, arguments, &call| observer.call(name, arguments) { record.call(name, arguments, &call) } }
207
218
  end
208
219
  end
209
220
 
@@ -17,7 +17,7 @@ module ActiveAgent
17
17
  # @example Disable sanitization entirely
18
18
  # ActiveAgent::ModelCapabilities.enabled = false
19
19
  module ModelCapabilities
20
- SAMPLING_PARAMS = [ :temperature, :top_p ].freeze
20
+ SAMPLING_PARAMS = [ :temperature, :top_p, :top_k ].freeze
21
21
 
22
22
  # Model families that reject sampling parameters with a 400:
23
23
  # - Anthropic thinking-first models (Opus 4.7+, Opus 5, Sonnet 5,
@@ -10,7 +10,7 @@ require_relative "concerns/tool_choice_clearing"
10
10
 
11
11
  # @private
12
12
  GEM_LOADERS = {
13
- anthropic: [ "anthropic", "~> 1.12", "anthropic" ],
13
+ anthropic: [ "anthropic", [ ">= 1.12", "< 3" ], "anthropic" ],
14
14
  openai: [ "openai", "~> 0.34", "openai" ],
15
15
  # Keep a tested floor: 1.2 lacks the provider APIs this adapter uses.
16
16
  ruby_llm: [ "ruby_llm", [ ">= 1.16", "< 3" ], "ruby_llm" ]
@@ -197,6 +197,7 @@ module ActiveAgent
197
197
  when :message_start
198
198
  api_message = Anthropic::Transforms.gem_to_hash(api_response_chunk.message)
199
199
  self.message_stack.push(api_message)
200
+ record_stream_usage(api_message[:usage])
200
201
  broadcast_stream_update(message_stack.last)
201
202
 
202
203
  # -> Content Block Create
@@ -239,6 +240,15 @@ module ActiveAgent
239
240
  self.message_stack.last.merge!(delta)
240
241
  self.stream_finish_reason = delta[:stop_reason] if delta[:stop_reason]
241
242
 
243
+ # message_delta carries the cumulative output tokens (and, on newer
244
+ # API versions, refreshed input counts); layer them over the usage
245
+ # message_start reported so the turn keeps a single running total.
246
+ delta_usage = Anthropic::Transforms.gem_to_hash(api_response_chunk.usage)&.compact
247
+ if delta_usage.present?
248
+ message_stack.last[:usage] = (message_stack.last[:usage] || {}).merge(delta_usage)
249
+ record_stream_usage(message_stack.last[:usage])
250
+ end
251
+
242
252
  # Message Completed [Full Message]
243
253
  when :message_stop
244
254
  api_message = Anthropic::Transforms.gem_to_hash(api_response_chunk.message)
@@ -51,7 +51,8 @@ module ActiveAgent
51
51
  )
52
52
 
53
53
  @messages = ::Anthropic::Resources::Messages.new(client: self)
54
- @completions = ::Anthropic::Resources::Completions.new(client: self)
54
+ # The anthropic gem 2.0 removed Text Completions along with this resource.
55
+ @completions = ::Anthropic::Resources::Completions.new(client: self) if defined?(::Anthropic::Resources::Completions)
55
56
  @beta = ::Anthropic::Resources::Beta.new(client: self)
56
57
  end
57
58
 
@@ -148,7 +148,8 @@ module ActiveAgent
148
148
  cached_tokens: sum_optional(self.cached_tokens, other.cached_tokens),
149
149
  cache_creation_tokens: sum_optional(self.cache_creation_tokens, other.cache_creation_tokens),
150
150
  reasoning_tokens: sum_optional(self.reasoning_tokens, other.reasoning_tokens),
151
- audio_tokens: sum_optional(self.audio_tokens, other.audio_tokens)
151
+ audio_tokens: sum_optional(self.audio_tokens, other.audio_tokens),
152
+ duration_ms: sum_optional(self.duration_ms, other.duration_ms)
152
153
  )
153
154
  end
154
155
 
@@ -263,6 +264,10 @@ module ActiveAgent
263
264
  **usage.slice(:input_tokens, :output_tokens, :service_tier),
264
265
  input_tokens: usage[:input_tokens] || 0,
265
266
  output_tokens: usage[:output_tokens] || 0,
267
+ # Anthropic's input_tokens excludes cache reads and writes, which are
268
+ # reported separately, so they are added to get every token processed.
269
+ total_tokens: (usage[:input_tokens] || 0) + (usage[:output_tokens] || 0) +
270
+ (usage[:cache_read_input_tokens] || 0) + (usage[:cache_creation_input_tokens] || 0),
266
271
  cached_tokens: usage[:cache_read_input_tokens],
267
272
  cache_creation_tokens: usage[:cache_creation_input_tokens],
268
273
  provider_details: usage.slice(:cache_creation, :server_tool_use).compact
@@ -110,6 +110,7 @@ module ActiveAgent
110
110
  # reasoning progress, kept from the reasoning item once it is done
111
111
  # - `:"response.content_part.done"` - content part completed
112
112
  # - `:"response.output_item.done"` - message, reasoning or function call completed
113
+ # - `:error`, `:"response.failed"` - provider failure
113
114
  # - `:"response.completed"` - response finished
114
115
  # - `:"response.incomplete"` - response cut short (e.g. max_output_tokens)
115
116
  #
@@ -174,6 +175,12 @@ module ActiveAgent
174
175
  when :"response.output_item.done"
175
176
  process_stream_output_item_done(api_response_event)
176
177
 
178
+ # Errors arrive as events even though the HTTP request succeeded.
179
+ when :error, :"response.failed"
180
+ error = api_response_event.type == :error ? api_response_event : api_response_event.response.error
181
+ message = [ error&.code, error&.message ].compact.join(": ")
182
+ raise Errors::ProviderError.new(message.presence || "OpenAI response failed", provider_tag: tag_name)
183
+
177
184
  # Response Completed, or cut short (e.g. by max_output_tokens) with
178
185
  # the output it has so far
179
186
  when :"response.completed", :"response.incomplete"
@@ -50,6 +50,14 @@ module ActiveAgent
50
50
 
51
51
  protected
52
52
 
53
+ # Executes an embedding request via Requesty's OpenAI-compatible API.
54
+ #
55
+ # @param parameters [Hash]
56
+ # @return [Hash] symbolized API response
57
+ def api_embed_execute(parameters)
58
+ client.embeddings.create(**parameters).as_json.deep_symbolize_keys
59
+ end
60
+
53
61
  # @see BaseProvider#api_response_normalize
54
62
  # @param api_response [OpenAI::Models::ChatCompletion]
55
63
  # @return [Hash] normalized response hash
@@ -32,6 +32,21 @@ module ActiveAgent
32
32
  alias_method :declared_parameters, :parameters
33
33
  alias_method :provider_options, :provider_params
34
34
 
35
+ # RubyLLM 2.2 registers a tool under its qualified name (namespace
36
+ # plus name) and asks each tool whether it defers. ActiveAgent's
37
+ # tools have no namespace and run when called.
38
+ def namespace
39
+ nil
40
+ end
41
+
42
+ def qualified_name
43
+ name
44
+ end
45
+
46
+ def deferred?
47
+ false
48
+ end
49
+
35
50
  private
36
51
 
37
52
  def deep_stringify(obj)
@@ -152,15 +152,16 @@ module ActiveAgent
152
152
  # Handle tool calls in chunk
153
153
  if chunk.tool_calls&.any?
154
154
  message[:tool_calls] ||= []
155
- chunk.tool_calls.each do |key, tool_call|
155
+ each_tool_call(chunk.tool_calls) do |key, tool_call|
156
156
  # Only the first delta of a call has its ID and name; the rest
157
157
  # are fragments of its arguments. A fragment belongs to the call
158
158
  # that shares its stream key (the index RubyLLM 2 numbers calls
159
159
  # by), or, without one as RubyLLM 1.16 leaves it for OpenAI, to
160
160
  # the latest call. RubyLLM 1.16 cannot preserve interleaved
161
161
  # OpenAI calls: its parser drops their indices and fragments.
162
- existing = if tool_call.id
163
- message[:tool_calls].find { |tc| tc[:id] == tool_call.id }
162
+ call_id = streamed_tool_call_id(tool_call)
163
+ existing = if call_id
164
+ message[:tool_calls].find { |tc| tc[:id] == call_id }
164
165
  else
165
166
  @stream_tool_calls[key] || message[:tool_calls].last
166
167
  end
@@ -169,7 +170,7 @@ module ActiveAgent
169
170
  existing[:function][:arguments] += fragment
170
171
  else
171
172
  existing = {
172
- id: tool_call.id,
173
+ id: call_id,
173
174
  type: "function",
174
175
  function: {
175
176
  name: tool_call.name,
@@ -492,7 +493,7 @@ module ActiveAgent
492
493
 
493
494
  # Handle tool calls
494
495
  if response.tool_calls&.any?
495
- hash[:tool_calls] = response.tool_calls.map do |id, tc|
496
+ hash[:tool_calls] = each_tool_call(response.tool_calls).map do |id, tc|
496
497
  {
497
498
  id: id,
498
499
  type: "function",
@@ -523,6 +524,41 @@ module ActiveAgent
523
524
  hash
524
525
  end
525
526
 
527
+ # Yields each of a message's tool calls with its key. RubyLLM 1.x and
528
+ # 2.0 to 2.1 keep them in a Hash keyed by call ID (or, on a streamed
529
+ # chunk, by stream index), so iterating yields pairs. RubyLLM 2.2's
530
+ # `ToolCall::Collection` yields the calls themselves and keeps the
531
+ # keys behind `each_key`, in the same order.
532
+ #
533
+ # @param calls [Hash, #each_key, nil]
534
+ # @return [Enumerator, Array] key and call pairs
535
+ def each_tool_call(calls, &block)
536
+ pairs = if calls.nil?
537
+ []
538
+ elsif calls.is_a?(Hash)
539
+ calls.to_a
540
+ elsif calls.respond_to?(:each_key)
541
+ calls.each_key.to_a.zip(calls.each.to_a)
542
+ else
543
+ calls.each.to_a.map { |call| [ call.id, call ] }
544
+ end
545
+ block ? pairs.each(&block) : pairs
546
+ end
547
+
548
+ # The provider's ID for a streamed tool call, present on its first
549
+ # delta and nil on the fragments. RubyLLM 2.2 keeps it in
550
+ # `provider_call_id` and leaves `id` (its own identifier, assigned when
551
+ # the message completes) nil on a chunk; earlier releases put the
552
+ # provider's ID in `id`.
553
+ #
554
+ # @param tool_call [RubyLLM::ToolCall]
555
+ # @return [String, nil]
556
+ def streamed_tool_call_id(tool_call)
557
+ provider_id = tool_call.provider_call_id if tool_call.respond_to?(:provider_call_id)
558
+ provider_id = nil if provider_id.to_s.empty?
559
+ provider_id || tool_call.id
560
+ end
561
+
526
562
  # A streamed tool call's arguments as the text they add to the JSON
527
563
  # string the call builds up. Most APIs stream that string; Anthropic
528
564
  # opens a call with an empty Hash for its input, and Gemini sends the
@@ -0,0 +1,17 @@
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 Runcrate
9
+ # ActiveModel type for casting and serializing Runcrate requests.
10
+ #
11
+ # Runcrate is OpenAI-compatible, so requests use the same shape as the
12
+ # OpenAI Chat API. This delegates entirely to
13
+ # OpenAI::Chat::RequestType.
14
+ RequestType = ActiveAgent::Providers::OpenAI::Chat::RequestType
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/options"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module Runcrate
8
+ # Configuration options for the Runcrate provider.
9
+ #
10
+ # Extends OpenAI::Options, overriding the base URL to point at
11
+ # Runcrate's OpenAI-compatible inference API and resolving the API key
12
+ # from RUNCRATE_API_KEY. Runcrate does not use organization or project
13
+ # identifiers.
14
+ #
15
+ # @example Basic configuration
16
+ # options = Options.new(api_key: ENV["RUNCRATE_API_KEY"])
17
+ #
18
+ # @see https://runcrate.ai/docs/models/overview
19
+ # @see https://runcrate.ai/dashboard/api-keys Runcrate API Keys
20
+ class Options < ActiveAgent::Providers::OpenAI::Options
21
+ # @!attribute base_url
22
+ # @return [String] API endpoint (default: "https://api.runcrate.ai/v1")
23
+ attribute :base_url, :string, as: "https://api.runcrate.ai/v1"
24
+
25
+ private
26
+
27
+ def resolve_api_key(kwargs)
28
+ kwargs[:api_key] ||
29
+ kwargs[:access_token] ||
30
+ ENV["RUNCRATE_API_KEY"]
31
+ end
32
+
33
+ # Not used as part of Runcrate
34
+ def resolve_organization_id(kwargs) = nil
35
+ def resolve_project_id(kwargs) = nil
36
+ end
37
+ end
38
+ end
39
+ 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 "runcrate/_types"
7
+
8
+ module ActiveAgent
9
+ module Providers
10
+ # Provides access to Runcrate's OpenAI-compatible inference API for
11
+ # open-weight models.
12
+ #
13
+ # Extends the OpenAI provider to work with Runcrate's OpenAI-compatible
14
+ # API. Model ids are the vendor-prefixed names Runcrate lists at
15
+ # +/v1/models+ (e.g. +deepseek-ai/DeepSeek-V4.1-Flash+).
16
+ #
17
+ # Runcrate is a plain OpenAI-compatible API: requests, responses and
18
+ # transforms are identical to the OpenAI Chat API, so this provider
19
+ # reuses OpenAI::Chat::RequestType and OpenAI::Chat::Transforms directly.
20
+ # The only Runcrate-specific configuration is the base URL and API key,
21
+ # which live in Runcrate::Options.
22
+ #
23
+ # @example Configuration in active_agent.yml
24
+ # runcrate:
25
+ # service: "Runcrate"
26
+ # api_key: <%= ENV["RUNCRATE_API_KEY"] %>
27
+ # model: "deepseek-ai/DeepSeek-V4.1-Flash"
28
+ #
29
+ # @see OpenAI::ChatProvider
30
+ # @see https://runcrate.ai/docs/models/overview
31
+ class RuncrateProvider < OpenAI::ChatProvider
32
+ # @return [String]
33
+ def self.service_name
34
+ "Runcrate"
35
+ end
36
+
37
+ # @return [Class]
38
+ def self.options_klass
39
+ Runcrate::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 Runcrate'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.7"
2
+ VERSION = "1.9.8"
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.7
4
+ version: 1.9.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-11-05 00:00:00.000000000 Z
11
+ date: 2026-11-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -162,16 +162,22 @@ dependencies:
162
162
  name: anthropic
163
163
  requirement: !ruby/object:Gem::Requirement
164
164
  requirements:
165
- - - "~>"
165
+ - - ">="
166
166
  - !ruby/object:Gem::Version
167
167
  version: '1.12'
168
+ - - "<"
169
+ - !ruby/object:Gem::Version
170
+ version: '3'
168
171
  type: :development
169
172
  prerelease: false
170
173
  version_requirements: !ruby/object:Gem::Requirement
171
174
  requirements:
172
- - - "~>"
175
+ - - ">="
173
176
  - !ruby/object:Gem::Version
174
177
  version: '1.12'
178
+ - - "<"
179
+ - !ruby/object:Gem::Version
180
+ version: '3'
175
181
  - !ruby/object:Gem::Dependency
176
182
  name: aws-sdk-bedrockruntime
177
183
  requirement: !ruby/object:Gem::Requirement
@@ -630,6 +636,9 @@ files:
630
636
  - lib/active_agent/providers/ruby_llm/tool_proxy.rb
631
637
  - lib/active_agent/providers/ruby_llm_provider.rb
632
638
  - lib/active_agent/providers/rubyllm_provider.rb
639
+ - lib/active_agent/providers/runcrate/_types.rb
640
+ - lib/active_agent/providers/runcrate/options.rb
641
+ - lib/active_agent/providers/runcrate_provider.rb
633
642
  - lib/active_agent/railtie.rb
634
643
  - lib/active_agent/railtie/schema_generator_extension.rb
635
644
  - lib/active_agent/schema_generator.rb