activeagent 1.6.4 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0332669c4ffee1ccbea3e1645e126acac6b4e957b980000e5d96baedc908638
4
- data.tar.gz: 1d6062b56a6e7fc0e76b965d308df706c3be563292344cdf68b500046e838784
3
+ metadata.gz: 4aee77fd4f67a50824b7153b66f31aa5f19293bad4397e032ec0a9e674a759d0
4
+ data.tar.gz: e261e8ca9d221edbc849791ed28c67bc491ccc31e1e18f5c4cbf7a9a96a806c5
5
5
  SHA512:
6
- metadata.gz: c0a940227737d94497c19e8760cf1241ea02f6078987f8cd27f78b0b1a1fb22ac90c8b924dff87e85a4bf8f13c952f6a36eec388dd806f56d230f5b38c9fe59b
7
- data.tar.gz: df01fdb757be0bcfa549086dfe0082db0ada892fc552600087d07fe9c1299a4aa4d4c897987cbda897832d11df50f19368261413647f63db4e636a4bb5bb98e5
6
+ metadata.gz: f5779f6207e07b1aec0ef14f72a5b010075b953c9025a12f6231ba164522c8b785cd9ca1bb297361abedc42aa535c468ef642978ec9fb439c79c9c0fe71ebd79
7
+ data.tar.gz: 1d2720a47e5e8c2a6c17a74cff893f1da2e89dd430b295bdcda4b9bdfb966afa8e75fbf95c0f063f5bca0bc8406d782e08b99fa6bed99b20230fb793ac3e518b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,223 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.7.0] - 2026-09-24
11
+
12
+ Releases `activeagent` and `actionagent` 1.7.0 from one tag. A minor release:
13
+ a mounted engine collects the evaluation reports applications publish with
14
+ `ActiveAgent::Evals::Publisher`, whose failures now say what the collector
15
+ refused and whether to retry; observed agents read their own traces in
16
+ evaluation criteria, the Tools and Traces tabs and deploy markers; host apps
17
+ extend the engine's models and controllers through concerns and mirror their
18
+ agent classes into the dashboard; and the Evaluations page is rebuilt around
19
+ runs. Run the install generator after upgrading (see Upgrading below).
20
+
21
+ Upgrading: the install generator emits two new migrations, both guarded
22
+ column by column: `ensure_agent_release_columns`, which adds the agent release
23
+ columns an install generated fresh on 1.6.2-1.6.4 never got (and those on
24
+ tables with a custom `table_name_prefix`), and `add_evaluation_report_identity`
25
+ for the evaluation report collector. Re-run
26
+ `bin/rails generate action_agent:install --skip` (`--skip` keeps your
27
+ initializer) and `bin/rails db:migrate`. A traces-only install needs neither;
28
+ if you re-run the generator there, pass `--traces_only` again, or it emits the
29
+ whole dashboard schema. Nothing else changes until an application publishes a
30
+ report to the mount.
31
+
32
+ ### Added
33
+
34
+ - **Host concerns for the engine's models and controllers** (`actionagent`).
35
+ `ActionAgent.model_concerns` is included into
36
+ `ActionAgent::ApplicationRecord` as it loads, and so into every engine
37
+ model; `ActionAgent.controller_concerns` into
38
+ `ActionAgent::ApplicationController`, ahead of its own callbacks, and so
39
+ into every dashboard controller. (The ingest endpoint,
40
+ `Api::TracesController`, inherits `ActionController::API` and keeps its
41
+ own bearer-token authentication; it is not touched.) Entries are modules
42
+ or their names, resolved
43
+ when the class loads. A host that pins the engine's tables to one database
44
+ connection, or carries its session helpers onto the dashboard's
45
+ controllers, configures that here instead of reopening the classes from a
46
+ `to_prepare` block.
47
+ - **The Evaluations page is rebuilt around runs** (`actionagent`). Evaluations
48
+ are the top level; every run is kept and listed with its movement against
49
+ the run before it (`+3 passed vs #2`, `partial run`, `#1 failed`), and a
50
+ sampling evaluation's run opens to a page of its own at
51
+ `<mount>/evaluations/:id/runs/:run_id` — a scorecard per model cohort, the
52
+ judge's verdict, the criteria × models matrix and what the run asks to fix.
53
+ A scenario suite's runs are the same full-width list; a row selects the run
54
+ the suite's model scorecards, fix items and scenario matrix show.
55
+ - **What a run cost is two figures, not one.** The agent's spend — what the
56
+ replayed or sampled interactions cost to serve, with a `per_interaction`
57
+ rate, the operating cost a per-conversation budget is set against — is
58
+ reported apart from the judge's, the judge model's own calls, which run
59
+ agent-to-agent and offline. Every judge call is metered under what it was
60
+ for (`scores["_judge_usage"]`: calls, tokens, estimated cost and how many
61
+ calls scored, recommended, ruled or authored KPIs), and `EvaluationRun#usage`
62
+ carries both sides. The page shows them on every run row, on the run, on a
63
+ page tile and in the footer, so the cost of operating an agent is never
64
+ inflated by the cost of checking it.
65
+ - A generation-sampling run records `scores["_cohorts"]`: per model, how many
66
+ generations were sampled, how many cleared every criterion, their latency
67
+ and tokens, and what those interactions cost to serve.
68
+ - `GET /api/evaluations` carries `run_count` and a `previous_run` summary per
69
+ evaluation, and every serialized run its `number` in the evaluation's
70
+ history, oldest first.
71
+ - The dashboard's object lists hold their metric columns in place: a trace,
72
+ interaction or evaluation run with nothing in a column prints a dash there
73
+ rather than sliding its neighbours over (`MetaStrip`).
74
+ - `ActiveAgent::Base.rendered_instructions` renders an agent's instructions
75
+ outside a generation, for a dashboard mirroring the class and for tests
76
+ asserting what a model is told. Both otherwise reached a private renderer
77
+ through `send`.
78
+ - `ActionAgent::AgentSync` mirrors host agent classes into dashboard `Agent`
79
+ records, setting the `agent_class_name` that `AgentRelease` already expects a
80
+ host to have written. The code owns what an agent is (name, description,
81
+ instructions, tools — rewritten every sync); the operator owns how it runs
82
+ (provider, model, status — set on create and preserved), so a model chosen in
83
+ the dashboard survives the next deploy.
84
+ - `ActionAgent.run_host_agent_classes` (default `false`) runs an agent that
85
+ mirrors a host class as that class, rather than as one rebuilt from the
86
+ record's `tools` and `instructions` columns. Dashboard-authored agents, which
87
+ name no class, keep using the dynamic runtime either way; a class name that no
88
+ longer resolves falls back to it rather than failing the run.
89
+ - MCP servers take `allowed_tools` and `require_approval` in the common
90
+ format. OpenAI's Responses API receives both as given; Anthropic receives
91
+ `allowed_tools` as an `mcp_toolset` entry in `tools` (every other tool of
92
+ the server disabled), beside any tools the request already declares
93
+ (#328, by @dark-panda).
94
+ - **A mounted engine collects published evaluation reports** (`actionagent`).
95
+ An application that runs its agents itself and evaluates them in-process
96
+ publishes the finished report with `ActiveAgent::Evals::Publisher`; until
97
+ now a self-hosted install had nowhere to receive it. On a full install (not
98
+ one generated with `--traces_only`, which answers 501),
99
+ `POST <mount>/api/evaluation_reports` takes the version-1 envelope and
100
+ returns the receipt the publisher checks, and
101
+ `ActionAgent::EvaluationReportImport` stores it as the engine's own rows:
102
+ the observed agent for the report's `source` and `agent_name`, an evaluation
103
+ named for its suite and scope (`orders (eu, support)`), its scenarios, and a
104
+ complete run with a result per scenario and model. The Evaluations page
105
+ shows it the way it shows a run the dashboard executed, with the summary
106
+ recomputed from the stored results. The endpoint authenticates exactly as
107
+ trace ingest does (`ingest_api_key`, or the tenant's key in multi-tenant
108
+ mode), takes only `application/json`, and places a report's agent wherever
109
+ `trace_owner_resolver` puts that tenant's traced agents. A `run_id` is
110
+ stored once per tenant, or once per install, and compared exactly: 201 for a
111
+ new report, 200 for an identical retry, 409 for different content. Invalid
112
+ reports, and an evaluation name the report does not own, are 422; a cap an
113
+ operator has to lift (observed agents per owner, 100 evaluations per agent,
114
+ 2,000 scenarios per evaluation) is 403; a new report over the new
115
+ `:evaluation_report` quota kind or past 30 new reports a minute from a key
116
+ is 429. An identical retry is never refused by the quota or the rate limit.
117
+ Bodies over 2 MiB are 413, and Rails never parses the body into params, so
118
+ nothing past the limit is read. `usage_recorder` is told
119
+ `:evaluation_report` for each stored report. Evaluation runs gain
120
+ `external_tenant`, `external_run_id` and `external_report_digest`, unique on
121
+ the first two (binary on MySQL); see Upgrading above.
122
+ `docs/evals/publication.md` documents the endpoint.
123
+
124
+ ### Changed
125
+
126
+ - The engine's judge blocks take `ActiveAgent::Evals::Judge`'s `kind:`, so a
127
+ scenario run's score, recommendation and verdict calls are metered apart.
128
+ - `ActionAgent::TelemetryTrace` inherits `ActionAgent::ApplicationRecord`
129
+ like every other engine model (`actionagent`), so it carries the model
130
+ concerns above, `AdapterAware` and the ownership API (`owner_association`,
131
+ `for_owner`) from the same place. Its table name is unchanged.
132
+ - `Api::TracesController`'s bearer authentication and its 429 quota body
133
+ live in `ActionAgent::Api::IngestAuthentication` (`actionagent`), which the
134
+ evaluation report collector shares. A host subclass that overrides
135
+ `authenticate_api_key!` is unaffected. The tenant's
136
+ `increment_telemetry_usage!` is still called for each trace ingest request,
137
+ and not for a report post.
138
+ - `add_agent_releases` reads `ActionAgent.table_name_prefix` for the tables
139
+ it alters (`actionagent`), so a newly generated copy works on an install
140
+ with a custom prefix. The trace table keeps its fixed name.
141
+ - A collector's rejection of `ActiveAgent::Evals::Publisher` says what it
142
+ refused and whether to retry. The message carries the `error` string of a
143
+ JSON object response body beside the HTTP status — control characters and
144
+ runs of whitespace collapsed to one space, the API key replaced with
145
+ `[FILTERED]`, cut to 200 characters; nothing else from the body — and what
146
+ to do next: never retry the report under the same `run_id` on a 409,
147
+ publish a smaller selection on a 413, correct the report on a 422, retry
148
+ later on a 408, 429 or 5xx, and resolve the cause first on anything else,
149
+ such as a 401 or 403. `Publisher::Error` carries `status`, `detail` and
150
+ `retryable?`.
151
+
152
+ ### Deprecated
153
+
154
+ - Assigning `ActionAgent.base_controller_class`, which has never been
155
+ consumed, warns through `ActionAgent.deprecator` and points at
156
+ `controller_concerns`. The accessor is removed in 2.0.
157
+
158
+ ### Fixed
159
+
160
+ - Telemetry criteria (`trace_error_rate`, `trace_latency`) score an observed
161
+ agent from its own traces (`actionagent`). They selected traces by
162
+ `Agent#telemetry_agent_class`, which appends `Agent` to a class name
163
+ lacking it, so an agent observed from an application reporting `SupportBot`
164
+ found no traces and scored nothing, and observed agents of one class ending
165
+ in `Agent` read each other's actions. `Agent#telemetry_traces` selects the
166
+ traces `AgentRegistrar` attributed to the agent, plus unattributed ones with
167
+ its service, class and action. Deleting an observed agent leaves its traces
168
+ unattributed, so the agent registered again for them still reads them. The
169
+ agent's Traces tab, its Tools tab usage
170
+ columns and the Interactions list filtered to it use the same selection. The
171
+ Traces tab asks for it with `GET /api/traces?agent_id=`, which answers 404
172
+ for an agent the caller cannot see; `agent=` still filters by class. On the
173
+ Metrics page filtered to a class, an observed agent's deploy markers now
174
+ show under the class its traces report (`Agent#reported_agent_class`).
175
+ Authored and mirrored agents read the traces they did before.
176
+ - `Agent.prompt(...).generate_later` and `Agent.embed(...).embed_later` run
177
+ their job instead of raising `ArgumentError: unknown keywords` in the
178
+ worker (#346).
179
+ - The agent builder and editor can reach every model a provider serves: the
180
+ OpenRouter catalog is no longer cut to its first 100 ids, and the model
181
+ field is a type-ahead over the catalog that also takes an unlisted id
182
+ (`actionagent`, #427).
183
+ - A rejected Create Agent shows its validation errors on the builder — a
184
+ summary and a message under each field — instead of leaving the form
185
+ silently in place. `POST`/`PATCH /api/agents` 422s carry `field_errors`
186
+ beside `errors` (`actionagent`, #426).
187
+ - An engine agent is refused a provider whose client gem the host has not
188
+ installed (`openai` for OpenAI, Ollama and OpenRouter; `anthropic` for
189
+ Anthropic) when the provider is chosen, with a validation error naming
190
+ the gem, instead of failing on its first run (`actionagent`, #416).
191
+ - A fresh `action_agent:install` creates the agent release columns with the
192
+ dashboard tables (`actionagent`): `release_digest` on agents,
193
+ `release_digest` and `revision` on agent versions, and `agent_version_id`
194
+ on agent runs and evaluation runs. The generator emits `add_agent_releases`
195
+ before the create-table migration, so on a fresh install it found none of
196
+ those tables and added nothing, and creating an agent run or an evaluation
197
+ run raised `NoMethodError` on `agent_version_id`. An install generated
198
+ that way on 1.6.2-1.6.4 gets the columns from the new
199
+ `ensure_agent_release_columns` migration (see Upgrading above).
200
+ - Every failure of `ActiveAgent::Evals::Publisher` to deliver a report
201
+ raises `Publisher::Error`. A malformed response (`Net::HTTPBadResponse`,
202
+ `Net::HTTPHeaderSyntaxError`, or a `Zlib::Error` from corrupt compression)
203
+ escaped as its own class and is now a retryable `Publisher::Error`. A
204
+ report that cannot be encoded as JSON (invalid UTF-8, `NaN`, nesting too
205
+ deep) is now a non-retryable one raised before anything is sent: it escaped
206
+ as `JSON::GeneratorError`, or was blamed on the collector as invalid JSON.
207
+ Only a network failure keeps its underlying error as `cause`, so a response
208
+ body or report content never reaches a log through the exception chain.
209
+ Invalid arguments raise `ArgumentError`, now also for a `report` that does
210
+ not convert to a hash (a string raised `NoMethodError` and `nil` published
211
+ an empty report) and a `nil` timeout (`TypeError`).
212
+ - The publisher strips whitespace around its API key, so the key it sends is
213
+ the one it filters from a collector's explanation, and refuses a key with
214
+ characters other than visible ASCII.
215
+
216
+ ### Security
217
+
218
+ - The dashboard's JSON API verifies the CSRF token (`actionagent`, #461). It
219
+ authenticates with the host's session cookie but had opted out of forgery
220
+ protection. The dashboard now sends the page's token with every mutating
221
+ request from one fetch shim; the MCP facade and trace ingest, which
222
+ authenticate by bearer token, stay exempt. A rejected request answers
223
+ `422` with `code: "invalid_csrf_token"`. Hosts that re-enabled protection
224
+ themselves (`ActionAgent::Api::BaseController.protect_from_forgery`) can
225
+ drop that line.
226
+
10
227
  ## [1.6.4] - 2026-09-22
11
228
 
12
229
  Releases `activeagent` and `actionagent` 1.6.4 from one tag. A patch on 1.6.3
@@ -12,6 +12,29 @@ module ActiveAgent
12
12
  include ActionView::Layouts
13
13
  end
14
14
 
15
+ class_methods do
16
+ # The agent's rendered instructions, outside a generation.
17
+ #
18
+ # Two surfaces need the text an agent would run on without running it: a
19
+ # dashboard that mirrors the class (ActionAgent::AgentSync) and a test
20
+ # asserting what the model is told. Both otherwise reach a private
21
+ # renderer through `send`, which couples them to internals that can move
22
+ # without notice.
23
+ #
24
+ # TicketAgent.rendered_instructions # => "You are the Ticket agent..."
25
+ #
26
+ # TicketAgent.rendered_instructions(topic: "tickets")
27
+ #
28
+ # @param template [String] template name, default "instructions"
29
+ # @param assigns [Hash] instance variables the template reads
30
+ # @return [String, nil] nil when the agent has no such template
31
+ def rendered_instructions(template: "instructions", **assigns)
32
+ agent = new
33
+ assigns.each { |name, value| agent.instance_variable_set(:"@#{name}", value) }
34
+ agent.send(:view_render_template, template)
35
+ end
36
+ end
37
+
15
38
  # Builds template lookup paths supporting both flat and nested directory structures.
16
39
  #
17
40
  # Templates are searched in priority order:
@@ -4,6 +4,7 @@ require "json"
4
4
  require "net/http"
5
5
  require "openssl"
6
6
  require "uri"
7
+ require "zlib"
7
8
 
8
9
  module ActiveAgent
9
10
  module Evals
@@ -11,11 +12,51 @@ module ActiveAgent
11
12
  # retain run_id when retrying: compatible collectors treat that identity as
12
13
  # immutable within the authenticated account. Delivery is blocking and does
13
14
  # not follow redirects with the account's bearer credential.
15
+ #
16
+ # Every failure to deliver raises Error. Invalid arguments raise
17
+ # ArgumentError before anything is sent.
14
18
  class Publisher
15
19
  DEFAULT_ENDPOINT = "https://api.activeagents.ai/v1/evaluations"
16
20
  MAX_BYTES = 2 * 1024 * 1024
17
- class Error < StandardError; end
21
+ DETAIL_LIMIT = 200
18
22
 
23
+ # Raised for every failed delivery. Only a network failure keeps the
24
+ # underlying error as its +cause+, so neither the response nor the
25
+ # report reaches a log through the exception chain.
26
+ #
27
+ # @!attribute [r] status
28
+ # @return [Integer, nil] the collector's HTTP status for a rejection, nil otherwise
29
+ # @!attribute [r] detail
30
+ # @return [String, nil] the collector's sanitized explanation of a rejection, if it gave one
31
+ class Error < StandardError
32
+ attr_reader :status, :detail
33
+
34
+ def initialize(message = nil, status: nil, detail: nil, retryable: false)
35
+ super(message)
36
+ @status = status
37
+ @detail = detail
38
+ @retryable = retryable
39
+ end
40
+
41
+ # Returns true when delivering the same report under the same run_id
42
+ # again may succeed.
43
+ def retryable?
44
+ @retryable
45
+ end
46
+ end
47
+
48
+ # Whether each rejection status is retryable, and what the caller should
49
+ # do about it. Other statuses fall back to the rules in +rejection+.
50
+ REJECTIONS = {
51
+ 409 => [ false, "the collector already holds a different report under this run_id; never retry this report with the same run_id" ],
52
+ 413 => [ false, "the report exceeds the collector's size limit; publish a smaller selection" ],
53
+ 422 => [ false, "correct what the collector refused before retrying" ],
54
+ 429 => [ true, "the account is over its quota or rate limit; retain the report and run_id and retry later" ]
55
+ }.freeze
56
+
57
+ # The key is sent as a bearer token and filtered from the collector's
58
+ # explanation, so it is limited to visible ASCII: the sanitizer in
59
+ # +collector_detail+ never alters it, and an echo of it always matches.
19
60
  def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, timeout: 10, open_timeout: 10)
20
61
  @uri = URI.parse(endpoint.to_s)
21
62
  unless @uri.is_a?(URI::HTTP) && @uri.host && !@uri.userinfo && !@uri.query && !@uri.fragment
@@ -24,12 +65,16 @@ module ActiveAgent
24
65
  unless @uri.scheme == "https" || %w[localhost 127.0.0.1 ::1].include?(@uri.hostname)
25
66
  raise ArgumentError, "Evaluation endpoint requires HTTPS except on loopback hosts"
26
67
  end
27
- raise ArgumentError, "Evaluation API key is required" if api_key.to_s.strip.empty?
28
68
 
29
- @api_key = api_key.to_s
30
- @timeout = Float(timeout)
31
- @open_timeout = Float(open_timeout)
32
- unless [ @timeout, @open_timeout ].all? { |value| value.finite? && value.positive? }
69
+ @api_key = api_key.to_s.strip
70
+ raise ArgumentError, "Evaluation API key is required" if @api_key.empty?
71
+ unless @api_key.match?(/\A[\x21-\x7E]+\z/)
72
+ raise ArgumentError, "Evaluation API key must contain only visible ASCII characters"
73
+ end
74
+
75
+ @timeout = Float(timeout, exception: false)
76
+ @open_timeout = Float(open_timeout, exception: false)
77
+ unless [ @timeout, @open_timeout ].all? { |value| value&.finite? && value.positive? }
33
78
  raise ArgumentError, "Evaluation delivery timeouts must be positive and finite"
34
79
  end
35
80
  rescue URI::InvalidURIError
@@ -43,7 +88,7 @@ module ActiveAgent
43
88
  identities.each do |key, value|
44
89
  raise ArgumentError, "#{key} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
45
90
  end
46
- body = JSON.generate(identities.merge("version" => 1, "report" => report.to_h))
91
+ body = encode(identities.merge("version" => 1, "report" => report_hash(report)))
47
92
  raise Error, "Evaluation report exceeds the 2 MiB delivery limit; publish a smaller selection" if body.bytesize > MAX_BYTES
48
93
 
49
94
  http = Net::HTTP.new(@uri.hostname, @uri.port)
@@ -57,19 +102,70 @@ module ActiveAgent
57
102
  request["Accept"] = "application/json"
58
103
  request.body = body
59
104
  response = http.request(request)
60
- unless %w[200 201].include?(response.code)
61
- raise Error, "Evaluation delivery rejected (HTTP #{response.code}); retain the report and run_id for retry"
62
- end
105
+ raise rejection(response) unless %w[200 201].include?(response.code)
63
106
 
64
- receipt = JSON.parse(response.body)
107
+ receipt = JSON.parse(response.body.to_s)
65
108
  unless receipt.is_a?(Hash) && receipt["run_id"] == run_id && receipt["status"] == "complete" && receipt["id"] && receipt["evaluation_id"]
66
- raise Error, "Evaluation collector returned an invalid completion receipt; retain the report and run_id for retry"
109
+ raise Error.new("Evaluation collector returned an invalid completion receipt; retain the report and run_id for retry", retryable: true)
67
110
  end
68
111
  receipt
69
112
  rescue JSON::ParserError
70
- raise Error, "Evaluation collector returned invalid JSON; retain the report and run_id for retry"
113
+ # The parser's message quotes the body.
114
+ raise Error.new("Evaluation collector returned invalid JSON; retain the report and run_id for retry", retryable: true), cause: nil
115
+ rescue Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error => e
116
+ # These messages can quote the response's status line, headers or body.
117
+ raise Error.new("Evaluation collector returned a malformed response (#{e.class}); retain the report and run_id for retry", retryable: true), cause: nil
71
118
  rescue IOError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => e
72
- raise Error, "Evaluation delivery failed (#{e.class}); retain the report and run_id for retry"
119
+ raise Error.new("Evaluation delivery failed (#{e.class}); retain the report and run_id for retry", retryable: true)
120
+ end
121
+
122
+ private
123
+
124
+ def report_hash(report)
125
+ hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
126
+ raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
127
+
128
+ hash
129
+ end
130
+
131
+ # Returns the envelope as JSON. A report holding invalid UTF-8, NaN,
132
+ # Infinity or nesting over JSON's depth limit raises a non-retryable
133
+ # Error, without the generator's message, which can quote the report.
134
+ def encode(envelope)
135
+ JSON.generate(envelope)
136
+ rescue JSON::JSONError, EncodingError => e
137
+ raise Error.new("Evaluation report cannot be encoded as JSON (#{e.class}); correct the report before publishing"), cause: nil
138
+ end
139
+
140
+ def rejection(response)
141
+ status = response.code.to_i
142
+ retryable, guidance = REJECTIONS.fetch(status) do
143
+ if status == 408 || (500..599).cover?(status)
144
+ [ true, "retain the report and run_id for retry" ]
145
+ else
146
+ [ false, "retain the report and run_id, and resolve the rejection before retrying" ]
147
+ end
148
+ end
149
+ detail = collector_detail(response.body)
150
+ reason = detail ? "HTTP #{status}: #{detail}" : "HTTP #{status}"
151
+ Error.new("Evaluation delivery rejected (#{reason}); #{guidance}", status: status, detail: detail, retryable: retryable)
152
+ end
153
+
154
+ # Returns the +error+ string of a JSON object body, or nil for any other
155
+ # body. Control characters and runs of whitespace become one space, the
156
+ # API key becomes [FILTERED], and the result is cut to DETAIL_LIMIT
157
+ # characters: `"answer\n\tis required"` → `"answer is required"`.
158
+ def collector_detail(body)
159
+ parsed = JSON.parse(body.to_s)
160
+ message = parsed["error"] if parsed.is_a?(Hash)
161
+ return unless message.is_a?(String)
162
+
163
+ detail = message.scrub.gsub(/[[:space:]\p{C}]+/, " ").strip.gsub(@api_key, "[FILTERED]")
164
+ return if detail.empty?
165
+
166
+ detail.length > DETAIL_LIMIT ? "#{detail[0, DETAIL_LIMIT - 1].rstrip}…" : detail
167
+ rescue JSON::ParserError, EncodingError
168
+ nil
73
169
  end
74
170
  end
75
171
  end
@@ -23,7 +23,21 @@ module ActiveAgent
23
23
  # argument, so a record arrives as the same record the caller passed and
24
24
  # the agent's authorization callbacks decide against a real user rather
25
25
  # than against nil.
26
- def perform(agent, agent_method, generation_method, args:, kwargs: nil, params: nil, actor: nil)
26
+ #
27
+ # +direct_generation_type+ marks a job enqueued by Agent.prompt(...) or
28
+ # Agent.embed(...), which have no action to call: +agent_method+ is then
29
+ # the synthetic +__direct_*__+ name, so the generation is rebuilt from
30
+ # +direct_args+ and +direct_options+ instead.
31
+ def perform(agent, agent_method, generation_method, args:, kwargs: nil, params: nil, actor: nil,
32
+ direct_generation_type: nil, direct_args: nil, direct_options: nil)
33
+ if direct_generation_type
34
+ generation = ActiveAgent::Parameterized::DirectGeneration.new(
35
+ agent.constantize, direct_generation_type.to_sym, params || {},
36
+ *Array(direct_args), **(direct_options || {}).symbolize_keys
37
+ )
38
+ return generation.public_send(generation_method)
39
+ end
40
+
27
41
  agent_class = params ? agent.constantize.with(params) : agent.constantize
28
42
  agent_class = agent_class.as(actor) if actor
29
43
  prompt = if kwargs
@@ -38,10 +38,20 @@ module ActiveAgent
38
38
  end
39
39
 
40
40
  # Handle mcps parameter (common format) -> transforms to mcp_servers (provider format)
41
- if params[:mcps]
42
- params[:mcp_servers] = normalize_mcp_servers(params.delete(:mcps))
43
- elsif params[:mcp_servers]
44
- params[:mcp_servers] = normalize_mcp_servers(params[:mcp_servers])
41
+ if params[:mcps] || params[:mcp_servers]
42
+ mcps = if params[:mcps]
43
+ params.delete(:mcps)
44
+ else
45
+ params[:mcp_servers]
46
+ end
47
+
48
+ params[:mcp_servers] = normalize_mcp_servers(mcps)
49
+
50
+ # A server's allowed_tools become an mcp_toolset entry beside the
51
+ # request's own tools. Added only when there is one: an empty
52
+ # list would send `"tools": null` on every MCP request.
53
+ mcp_tools = normalize_mcp_tools(mcps)
54
+ params[:tools] = Array(params[:tools]) + mcp_tools if mcp_tools.present?
45
55
  end
46
56
 
47
57
  params
@@ -111,6 +121,49 @@ module ActiveAgent
111
121
  end
112
122
  end
113
123
 
124
+ # Builds Anthropic mcp_toolset tool entries from MCP server allowed_tools.
125
+ #
126
+ # Accepts allowed_tools entries as tool-name strings/symbols or hashes
127
+ # with a :name key; entries in any other format are ignored.
128
+ #
129
+ # @param mcp_servers [Array<Hash>]
130
+ # @return [Array<Hash>, nil] toolset entries, or nil when none were extracted
131
+ def normalize_mcp_tools(mcp_servers)
132
+ return nil unless mcp_servers.is_a?(Array)
133
+
134
+ result = mcp_servers.filter_map do |server|
135
+ next unless server.is_a?(Hash)
136
+
137
+ server_hash = server.deep_symbolize_keys
138
+ allowed_tools = server_hash[:allowed_tools]
139
+ next unless allowed_tools.is_a?(Array)
140
+
141
+ configs = allowed_tools.filter_map { |tool|
142
+ name = case tool
143
+ when String, Symbol
144
+ tool.to_s
145
+ when Hash
146
+ (tool[:name] || tool["name"]).to_s
147
+ end
148
+
149
+ [ name, { enabled: true } ] if name.present?
150
+ }.to_h
151
+
152
+ next if configs.empty?
153
+
154
+ {
155
+ type: "mcp_toolset",
156
+ mcp_server_name: server_hash[:name],
157
+ default_config: {
158
+ enabled: false
159
+ },
160
+ configs: configs
161
+ }
162
+ end
163
+
164
+ result.presence
165
+ end
166
+
114
167
  # Normalizes tool_choice from common format to Anthropic gem model objects.
115
168
  #
116
169
  # The Anthropic gem expects tool_choice to be a model object (ToolChoiceAuto,
@@ -96,6 +96,14 @@ module ActiveAgent
96
96
  server_url: server_hash[:url] || server_hash[:server_url]
97
97
  }
98
98
 
99
+ if server_hash[:require_approval]
100
+ result[:require_approval] = server_hash[:require_approval]
101
+ end
102
+
103
+ if server_hash[:allowed_tools]
104
+ result[:allowed_tools] = server_hash[:allowed_tools]
105
+ end
106
+
99
107
  # Keep authorization field (OpenAI uses 'authorization', not 'authorization_token')
100
108
  if server_hash[:authorization]
101
109
  result[:authorization] = server_hash[:authorization]
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.6.4"
2
+ VERSION = "1.7.0"
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.6.4
4
+ version: 1.7.0
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-09-23 00:00:00.000000000 Z
11
+ date: 2026-09-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack