activeagent 1.5.2 → 1.6.2

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: 73e63c87b700b6dec4e3410ae0547ff1c02714fbe79b24175447fb5638a9fe3b
4
- data.tar.gz: ef361dc3bb7dcab252c0fadf1fb526e689b97b21386a2d50b6ba6eb96a84100a
3
+ metadata.gz: b14c71e437e0c5701b6300861adb5d8b79eba328602e255b38928e6030584a2b
4
+ data.tar.gz: c5eeeedce5730d88609b5131304571f9d7ecc12d3177ada460de7a4dd56ad61d
5
5
  SHA512:
6
- metadata.gz: 7ed21c4a284e17d0014ef83b09fc56741b96cbb83f219f38dc3e8ad87f511f9ed5b3d902e1b1e9c7e4db11c83bce07ed0effbb2f44d85c35d0da4738598744b3
7
- data.tar.gz: c3634cfbe8e532889e2ef82b7080b30f0233a0faf1e81b5c37995949a8c7a6d5e6ebf9e3800e39457c54de3803d22b3545d4606d448867a777915cabf1a39b15
6
+ metadata.gz: d76b024171c0a84e4ecc6f6618ccb92d1e94a215f15e15839e8b78b37c8bcd6a312626b8dda996cca23bb2ebcab325fc08fab289689d66143855fa0c372ffb40
7
+ data.tar.gz: 02605c62c4d22bc001462ee13451fce5f578cb43adb8f9380c8f116ef589629cafb175e10ce019fa073b9086382c2e8dd09bb03d9aab52813f75852a27e43aeb
data/CHANGELOG.md CHANGED
@@ -7,6 +7,272 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.2] - 2026-09-16
11
+
12
+ Releases `activeagent` and `actionagent` 1.6.2 from one tag.
13
+
14
+ Agents gain releases: a digest of everything the model is given, cut on
15
+ deploy and pinned to every trace, run and evaluation run, so a score is a
16
+ statement about a specific release and a regression is attributable to the
17
+ change that caused it. Around it, five dashboard fixes: an evaluation
18
+ created on MySQL can be run, the Tools tab reads the same `agent.tools` the
19
+ runner does, a container-valued query parameter is coerced instead of
20
+ raising, a recording's detail response no longer carries the visitor's
21
+ cookies and web storage, and the MCP endpoint answers an unsupported method with 405
22
+ instead of the dashboard page. `sign_in_path` and `sign_out_path` are now
23
+ documented.
24
+
25
+ Upgrading: the install generator emits a new `add_agent_releases` migration
26
+ (guarded column by column); run it. Cutting a release is
27
+ `rake action_agent:agents:release[REVISION]` in the deploy.
28
+
29
+ ### Added
30
+
31
+ - **Agents have releases, and every trace, run and evaluation says which one
32
+ it ran under.** `ActiveAgent::Release` gives each agent class a digest of
33
+ what the model is given — provider and model, generation options minus
34
+ credentials, the actions, the prompt templates on disk, and the tools and
35
+ delegations it declares — so two deploys of the same agent share a digest
36
+ and any change to those inputs is a new one, with no number to bump.
37
+ `ActiveAgent::Release.revision` carries the deploy alongside (a git SHA;
38
+ read from `SERVICE_VERSION`, `GIT_SHA`, `KAMAL_VERSION` and friends when
39
+ not set). The instrumentation stamps `agent.version` and `agent.revision`
40
+ on every generation's root span, and every trace gets `service.version`.
41
+ In the dashboard, `rake action_agent:agents:release[REVISION]` cuts an
42
+ `AgentVersion` for each agent whose code changed since the last release —
43
+ idempotent, so it belongs in the deploy — `rake action_agent:agents:versions`
44
+ lists them, and `Agent#record_release!` is the call behind both for a host
45
+ that syncs agents its own way. Traces are pinned to the release their root
46
+ span names, runs and evaluation runs to the version current when they
47
+ started (`agent_version_id` on all three; the install generator emits the
48
+ migration). A version's JSON carries `release`, `release_digest` and
49
+ `revision`, so the Versions tab tells a deploy from an edit. For that to
50
+ reach a host's own agents, a trace from a class the host mirrors into the
51
+ dashboard is now attributed to that mirror — the registrar matched only on
52
+ service, class *and* action, so every code-path trace registered an
53
+ observed per-action twin beside the synced record and could never be
54
+ pinned to its release.
55
+
56
+ ### Fixed
57
+
58
+ - **An evaluation created on MySQL can be run.** MySQL cannot give a JSON
59
+ column a default, so an evaluation saved there without `config` read it
60
+ back as `nil`, and `compare_models` raised before the runner did anything
61
+ else. `config` and `criteria` now read as the empty value their column
62
+ default supplies on other databases. (#417)
63
+ - **The Tools tab now says which schema tools an agent is offered, and
64
+ lets you change it.** The editor listed every schema tool as enabled and
65
+ read-only whatever `agent.tools` held — *"a checkbox that cannot add or
66
+ remove the tool is a control that changes nothing"* — while evaluations,
67
+ dashboard runs and the MCP facade offered exactly what that column named.
68
+ An agent whose roster had been emptied over the API ran a suite with no
69
+ tools (1/8, `expected tool not called ×6`) under a tab reading "12
70
+ enabled". A schema tool's row now reads the roster and is switchable, and
71
+ every schema tool the host declares has a row, off unless the roster names
72
+ it — any agent may enable any of them, and a tool switched off has to keep
73
+ its row to be switched back on. A tool the agent class declares in code is
74
+ still reported rather than selected: the class offers it, and no checkbox
75
+ could change that.
76
+ - **A container-valued query parameter no longer 500s the dashboard API.**
77
+ `page`, `per_page`, `days`, `minutes`, `limit` and `after_sequence` were
78
+ read with `to_i`, which neither an Array (`minutes[]=1&minutes[]=2`) nor a
79
+ nested object (`page[x]=1`) answers. `Api::BaseController` now coerces
80
+ them: a multi-valued parameter means its first value, a nested object falls
81
+ back to the default, and the clamps that bounded the number still apply.
82
+ `sandboxes#compare` answers a `providers` value that is not a list of
83
+ names with a 400 instead of a `NoMethodError`.
84
+ - **A session recording's `show` no longer returns the visitor's cookies and
85
+ web storage.** Every other read path redacted the handoff state, but the
86
+ detail response carried `cookies`, `session_storage` and `local_storage`
87
+ unscrubbed, both as its own key and nested inside `metadata`. Both are now
88
+ stripped; only `#handoff` returns them, to the recording's owner. (#456)
89
+
90
+ ## [1.6.1] - 2026-09-16
91
+
92
+ Releases `activeagent` and `actionagent` 1.6.1 from one tag.
93
+
94
+ A patch for two defects that share a failure mode: each one turns a broken
95
+ run into a plausible-looking success rather than an error. A date filter that
96
+ matched nothing reported zero instead of raising, and an agent reported that
97
+ zero as fact; telemetry that was enabled but never instrumented wrote no
98
+ traces while every configuration signal read healthy. Neither surfaced in a
99
+ test suite, because neither produces a failure — only a confident wrong
100
+ answer and an empty table.
101
+
102
+ No new public surface and no behaviour change for anything that was already
103
+ working, so a patch under semver. Suites that filter on a date column will
104
+ report different — correct — numbers after upgrading; read the first run as a
105
+ corrected baseline.
106
+
107
+ ### Fixed
108
+
109
+ - **A range filter on a `SchemaTools` column no longer matches nothing and
110
+ reports zero.** `permitted_filters!` validated the column against the
111
+ allowlist but passed the value through untouched, so a range hash reached
112
+ `where` unrecognized and Rails compiled `where(due_date: {"before" => x})`
113
+ to `due_date = NULL` — a predicate that matches no row. The tool returned
114
+ `{count: 0}` with no error and the model read it as a truthful empty
115
+ answer: "0 overdue tickets" against a database holding four. Equality
116
+ filters were unaffected, which is why this went unnoticed. Comparisons are
117
+ now built through Arel with the column's own type cast, under the operators
118
+ `before`, `after`, `lt`, `lte`, `gt`, `gte`, `on_or_before` and
119
+ `on_or_after`; two bounds may be given together to express a window; and an
120
+ operator outside that set raises `UnpermittedAttribute` rather than
121
+ returning zero, consistent with how an undeclared column is already
122
+ rejected. Ranges are offered for date, datetime, time and numeric columns
123
+ only — a lexical `>` on a name column answers a question nobody asked.
124
+ - **A range filter is now discoverable.** `filter_properties` described a date
125
+ column as a bare `{type: "string", format: "date"}`, so the tool surface
126
+ could not express "before today" at all and a model asking the question
127
+ correctly still had no way to ask it. Comparable columns are now offered as
128
+ `anyOf: [scalar, range object]`, with the operator roster in the schema.
129
+ - **Telemetry enabled from a host app's initializer now installs
130
+ instrumentation.** The railtie prepended `GenerationInstrumentation` only
131
+ when `Telemetry.enabled?` was already true as railties ran — before
132
+ `config/initializers/*.rb`. An app that configures telemetry in its own
133
+ initializer, which is what the documentation shows, was therefore never
134
+ instrumented: `enabled?` answered true, `local_storage` was on, the trace
135
+ model resolved and the store lambda worked when called directly, and no
136
+ generation ever produced a span to store. `configure` now installs as well
137
+ when the resulting configuration is enabled; `instrument_telemetry!` is
138
+ idempotent, so the railtie path and the configure path cannot
139
+ double-prepend and initializer order stops mattering.
140
+
141
+ ## [1.6.0] - 2026-09-14
142
+
143
+ Releases `activeagent` and `actionagent` 1.6.0 from one tag.
144
+
145
+ A minor, not a patch. The cycle that began after 1.5.2 gives an agent a
146
+ caller — `current_user`, carried from whatever authenticated the call into
147
+ every `before_action`, every tool, every delegated sub-agent, every run over
148
+ MCP and every evaluation replay — so an authorization gem has something to
149
+ decide against. Around it: schema tools defined at runtime rather than only
150
+ in a file, a generator that writes the first one, those tools served
151
+ directly over MCP, and an evaluation that calls a fabricated answer a fault
152
+ instead of grading it as an honest gap. That is new public surface in both
153
+ gems, which is a minor under semver even though 1.5.2 shipped a feature as a
154
+ patch.
155
+
156
+ Two notes for upgrades. `tools_succeeded` is now awarded only for a tool the
157
+ scenario expected, so a suite that was quietly scoring wrong-tool runs as
158
+ partial successes will report lower — read the first run as a corrected
159
+ baseline. And `actor:` is now stripped from tool arguments and from
160
+ `params[params][actor]`: the caller is a property of the run, set once by
161
+ whatever authenticated it, and can no longer be named by the model or by a
162
+ client.
163
+
164
+ The engine's floor on the framework (`activeagent >= 1.4`) is unchanged and
165
+ still correct: 1.6.0 satisfies it.
166
+
167
+ ### Added
168
+
169
+ - **An evaluation replay runs as the evaluation's owner.** The scenario runner
170
+ handed `Agent#test_execute` no caller, so every tool a replay called ran
171
+ unattributed and a host scope answered empty — the suite graded an agent
172
+ that never saw a row. When agents are owned per user, a replay now runs as
173
+ the user who owns the evaluation, as a run over MCP runs as the key's
174
+ owner. A multi-tenant install still replays unattributed (an account is who
175
+ is billed, not who is allowed) unless a host adapter runs the suite itself.
176
+ - **The MCP facade serves the host's schema tools directly.** `tools/list`
177
+ at `POST <mount>/mcp` now offers every tool the dashboard's discovered
178
+ `ActiveAgent::SchemaTools` classes generate — `find_<records>`,
179
+ `count_<records>`, `get_<record>` — beside the `run_<slug>` agents, each
180
+ with its own parameter schema, and `tools/call` runs one as the key's
181
+ caller through the host's own scope, exactly as it would inside an agent
182
+ run. A client that only needs the rows no longer has to ask an agent for
183
+ them. A boundary violation is a tool result with `isError`, a refusal from
184
+ the host's scope is a JSON-RPC `-32003`, and neither the execution switch
185
+ nor the execution quota applies, because nothing generates. Set
186
+ `ActionAgent.mcp_schema_tools = false` to keep the tools reachable only
187
+ through agents. Closes #439.
188
+ - **A delegated run inherits its parent's caller.** `delegate_to` hands the
189
+ sub-agent the parent's `current_user` before its action runs, so its own
190
+ `before_action` callbacks and any scope its tools read through decide
191
+ against the same person. A parent authorized as one user no longer hands
192
+ its specialists an unattributed run — which a correctly written host scope
193
+ reads as "no access", a wrong answer wearing a right one's clothes. A
194
+ parent with no caller still delegates an unattributed run, never someone
195
+ else's.
196
+ - **`rails generate active_agent:schema_tools Reservation`** writes a starter
197
+ `ActiveAgent::SchemaTools` class under `app/agent_tools`. It exposes nothing
198
+ beyond `id` until a column is moved into `filterable` or `returns`; every
199
+ column the model has is listed, commented out, with its type, so the
200
+ allowlist is a review step rather than a blank page, and columns that look
201
+ like secrets are left off the list. Reads are scoped through
202
+ `<Model>Policy::Scope` when it exists (`--policy` / `--no-policy` decide
203
+ explicitly). This is #440's second option: the roster is still declared,
204
+ once, but the declaration is no longer written from scratch. (#440)
205
+ - **An agent knows who it is running for, so an authorization gem has
206
+ something to decide against.** `ActiveAgent::Base#current_user` carries the
207
+ caller, assigned by whatever authenticated the call
208
+ (`MyAgent.as(current_user).ask(...)`) and readable from every
209
+ `before_action`, so Pundit, CanCanCan or Action Policy authorize an agent
210
+ the way they authorize a controller. A refusal inside a tool call is
211
+ returned to the model as `{ error: ... }` so it can say it is not allowed;
212
+ a refusal anywhere else is raised to the caller. `denies_with` names a
213
+ gem's own error as a refusal.
214
+ - **The dashboard fills that seam.** A run records the caller that started it
215
+ (a Global ID, so a worker on another machine authorizes as the same
216
+ person), passes it to every tool as `actor:`, and runs the agent through
217
+ `as(actor)` — which is what a `SchemaTools` `scope` block has been waiting
218
+ for since 1.5.0. `ActionAgent.agent_actor_resolver` overrides who that is.
219
+ - **Agents reached over MCP run as the key's caller** rather than
220
+ unattributed, and an agent that refuses answers as a JSON-RPC error
221
+ (`-32003`) instead of as an empty result.
222
+ - **Schema tools can be defined at runtime.** `ActiveAgent::SchemaTools.define(Reservation,
223
+ filterable:, returns:, scope: | policy:)` builds the same bounded roster a
224
+ file under `app/agent_tools` would — same allowlists, same `call`, named
225
+ `ReservationTools` for logs — from a declaration held anywhere: a table, a
226
+ dashboard form, a test. The class is registered under its model and a
227
+ redefinition replaces the previous one, so a registry rebuilt on every
228
+ change holds one class per model; `undefine` drops it. The dashboard
229
+ discovers registry entries beside the files and lets a runtime definition
230
+ supersede a file for the same model. What is persisted, and where, stays the
231
+ host's decision; this is the seam a persisted declaration builds on. (#441)
232
+ - **A fabricated answer is now a fault.** `Diagnosis` raises `ungrounded_answer`
233
+ when an agent that had tools called none, did not say it could not answer,
234
+ and still stated specifics — a count, a record id, a date — that no tool
235
+ supplied. Where the scenario names an expected tool, `expected_tool_not_called`
236
+ says the same thing in its summary and carries `ungrounded: true`, so an
237
+ invented answer no longer reads like an honest gap. Both reach the judge,
238
+ which is what turns them into a suggested tool. An agent with no tools at all
239
+ is not flagged: it answers from its instructions by design, and whether that
240
+ is acceptable is the judge's grade, not a mechanical one. (#433)
241
+
242
+ ### Changed
243
+
244
+ - **A wrong tool no longer outscores no tool.** `tools_succeeded` is awarded
245
+ only for a tool the scenario expected (or any tool when it expects none):
246
+ a tool that ran without erroring was evidence of the task only by accident,
247
+ and a scenario that called the wrong tool scored higher than one that called
248
+ nothing. (#433)
249
+ - **The judge reads more of a scenario's notes** — 1,500 characters rather
250
+ than 300 — because a suite's notes are often its rubric and the "must not"
251
+ clause tends to come last. (#433)
252
+
253
+ ### Fixed
254
+
255
+ - **The caller can no longer be named by the model, or by the client.**
256
+ `actor:` reached `AgentToolbox.call` in the same keyword namespace as the
257
+ arguments a provider parsed out of a model's tool call, and
258
+ `params[params][actor]` in an execute request would have won over the
259
+ controller's own. Both are now stripped: the caller is a property of the
260
+ run, set once by whatever authenticated it. Scoped `SchemaTools` reads are
261
+ also excluded from the tool-result cache, so one caller's rows are never
262
+ replayed for the next.
263
+ - **A superseded runtime tool class is no longer offered twice.** Discovery
264
+ read every `SchemaTools` subclass out of `descendants`, where a class built
265
+ at runtime stays until it is collected, so rebuilding a model's tools
266
+ accumulated stale duplicates. Runtime-built classes are now read from the
267
+ registry only. (#441)
268
+ - **A persisted model selection re-runs under the provider it ran under.**
269
+ `Evals::ModelSpec.parse_all` re-parsed a round-tripped spec from its label,
270
+ so `anthropic/claude-sonnet-4.5` run through OpenRouter came back as
271
+ Anthropic's own `claude-sonnet-4.5` as soon as that provider's gem was
272
+ installed — and the re-run failed for want of an Anthropic credential. A
273
+ hash naming both `provider` and `model` is now rebuilt as it was; a bare
274
+ label is still parsed. The dashboard's "re-run" of a saved selection is
275
+ the path this fixes.
10
276
  ## [1.5.2] - 2026-09-11
11
277
 
12
278
  Releases `activeagent` and `actionagent` 1.5.2 from one tag.
@@ -4,6 +4,7 @@ require "active_support/core_ext/hash/except"
4
4
  require "active_support/core_ext/module/anonymous"
5
5
  require "active_support/core_ext/string/inflections"
6
6
 
7
+ require "active_agent/concerns/authorization"
7
8
  require "active_agent/concerns/callbacks"
8
9
  require "active_agent/concerns/delegation"
9
10
  require "active_agent/concerns/observers"
@@ -11,6 +12,7 @@ require "active_agent/concerns/parameterized"
11
12
  require "active_agent/concerns/preview"
12
13
  require "active_agent/concerns/provider"
13
14
  require "active_agent/concerns/queueing"
15
+ require "active_agent/concerns/release"
14
16
  require "active_agent/concerns/rescue"
15
17
  require "active_agent/concerns/streaming"
16
18
  require "active_agent/concerns/tooling"
@@ -43,15 +45,22 @@ module ActiveAgent
43
45
  include AbstractController::Caching
44
46
 
45
47
  include Callbacks
48
+ # After Rescue: the refusal handler is registered with rescue_from.
46
49
  include Delegation
47
50
  include Parameterized
48
51
  include Provider
49
52
  include Queueing
53
+ include Release
50
54
  include Rescue
51
55
  include Streaming
52
56
  include Tooling
53
57
  include View
54
58
 
59
+ # Last of the behaviour concerns: its rescue_from must sit on top of the
60
+ # handlers an agent registers for its own authorization gem, so a host
61
+ # mapping Pundit::NotAuthorizedError keeps deciding what a refusal means.
62
+ include Authorization
63
+
55
64
  include Observers
56
65
  include Previews
57
66
 
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ # Raised when an agent refuses a call on the current caller's behalf.
5
+ #
6
+ # Hosts usually raise their authorization gem's own error instead
7
+ # (+Pundit::NotAuthorizedError+, +CanCan::AccessDenied+) and name it with
8
+ # {Authorization::ClassMethods#denies_with}; this exists so an agent that
9
+ # has no gem still has something to raise, and so the framework has one
10
+ # class to describe a refusal with.
11
+ class NotAuthorized < StandardError
12
+ # @return [Symbol, String, nil] the action or tool that was refused
13
+ attr_reader :action
14
+ # @return [Object, nil] the caller the refusal was decided against
15
+ attr_reader :actor
16
+
17
+ def initialize(message = nil, action: nil, actor: nil)
18
+ @action = action
19
+ @actor = actor
20
+ super(message || default_message)
21
+ end
22
+
23
+ private
24
+
25
+ def default_message
26
+ who = actor.nil? ? "an unauthenticated caller" : "the current caller"
27
+ what = action ? "`#{action}`" : "this agent"
28
+ "#{who} is not allowed to call #{what}"
29
+ end
30
+ end
31
+
32
+ # Carries the caller an agent runs on behalf of, so an agent's callbacks can
33
+ # authorize with whatever the host app already uses.
34
+ #
35
+ # An agent reached over MCP, from a dashboard run, or from a controller is
36
+ # acting *for someone*. Without that someone, an authorization gem has
37
+ # nothing to decide against: a Pundit scope handed +nil+ correctly resolves
38
+ # to the empty set, so a perfectly wired agent answers "there are no
39
+ # tickets" instead of refusing — a wrong answer that reads like a true one.
40
+ #
41
+ # == The seam
42
+ #
43
+ # {#current_user} is assigned by whatever authenticated the call and is
44
+ # readable from every callback and action:
45
+ #
46
+ # class TicketAgent < ApplicationAgent
47
+ # before_action :authorize_tickets!
48
+ #
49
+ # def find_tickets(**filters)
50
+ # TicketTools.call("find_tickets", actor: current_user, **filters)
51
+ # end
52
+ #
53
+ # private
54
+ #
55
+ # def authorize_tickets!
56
+ # raise ActiveAgent::NotAuthorized.new(action: action_name, actor: current_user) unless
57
+ # TicketPolicy.new(current_user, Ticket).index?
58
+ # end
59
+ # end
60
+ #
61
+ # TicketAgent.as(current_user).find_tickets.generate_now
62
+ #
63
+ # Any gem works, because the framework never interprets the actor — Pundit's
64
+ # +authorize+/+policy_scope+, CanCanCan's +can?+, Action Policy's
65
+ # +authorize!+, or a plain predicate. +before_action+ is the same
66
+ # +AbstractController+ chain a controller uses, including +only:+/+except:+,
67
+ # so a roster can be authorized tool by tool.
68
+ #
69
+ # == It is never model input
70
+ #
71
+ # The actor is an attribute of the run, set out of band by the caller. It is
72
+ # deliberately not part of +params+ and not a tool argument: everything a
73
+ # model emits is attacker-reachable through the documents it reads, and an
74
+ # actor a model can name is not an authorization boundary. Assigning it is
75
+ # the caller's job, once, before the generation starts.
76
+ #
77
+ # == What a refusal does
78
+ #
79
+ # A refusal inside a *tool call* is returned to the model as an error result
80
+ # ({ error: ... }), so it can tell the user it is not allowed to look rather
81
+ # than dying mid-run or, worse, reporting an empty result set as fact. A
82
+ # refusal anywhere else — the action the caller asked for — is raised, so
83
+ # the MCP client or controller that asked gets an error instead of an
84
+ # answer that silently covers less ground than it appears to.
85
+ #
86
+ # {ClassMethods#denies_with} is how a gem's own error joins that rule:
87
+ #
88
+ # class ApplicationAgent < ActiveAgent::Base
89
+ # denies_with Pundit::NotAuthorizedError
90
+ # end
91
+ module Authorization
92
+ extend ActiveSupport::Concern
93
+
94
+ included do
95
+ # The caller this generation runs on behalf of, or nil when it runs
96
+ # unattributed. Whatever the host uses as an actor: a User, an API
97
+ # key's owner, a service account.
98
+ attr_accessor :current_user
99
+
100
+ # Exception classes that mean "the caller may not do this". Declared
101
+ # rather than guessed: only the host knows which of its errors are a
102
+ # refusal and which are a bug.
103
+ class_attribute :authorization_errors, instance_writer: false,
104
+ default: [ ActiveAgent::NotAuthorized ].freeze
105
+ end
106
+
107
+ class_methods do
108
+ # Treats +classes+ as refusals, so raising one inside a tool call
109
+ # reports to the model instead of ending the run.
110
+ #
111
+ # @param classes [Array<Class>] exception classes from an authorization gem
112
+ # @return [Array<Class>] every class now treated as a refusal
113
+ #
114
+ # @example
115
+ # denies_with Pundit::NotAuthorizedError, CanCan::AccessDenied
116
+ def denies_with(*classes)
117
+ self.authorization_errors = (authorization_errors | classes.flatten).freeze
118
+ end
119
+
120
+ # Runs the agent on behalf of +actor+.
121
+ #
122
+ # @param actor [Object, nil] the caller, or nil to run unattributed
123
+ # @return [ActiveAgent::Parameterized::Agent] a proxy carrying the actor
124
+ #
125
+ # @example
126
+ # SupportAgent.as(current_user).answer(question).generate_now
127
+ #
128
+ # @example With parameters
129
+ # SupportAgent.as(current_user).with(locale: :en).answer(question)
130
+ def as(actor)
131
+ ActiveAgent::Parameterized::Agent.new(self, {}, actor: actor)
132
+ end
133
+ end
134
+
135
+ # Whether a refusal right now would be reported to the model rather than
136
+ # raised to the caller.
137
+ # @return [Boolean]
138
+ def tool_call?
139
+ @_active_agent_tool_call ||= false
140
+ end
141
+
142
+ # Marks the block as a tool call, so a refusal inside it becomes a result
143
+ # the model can read. Nested calls keep the outer marking.
144
+ # @api private
145
+ def with_tool_call
146
+ previous = @_active_agent_tool_call
147
+ @_active_agent_tool_call = true
148
+
149
+ result = yield
150
+ # A host's own rescue_from handler runs inside `process`, and
151
+ # ActiveSupport::Rescuable answers with the exception itself — so a
152
+ # refusal arrives either raised or returned, and both mean the same
153
+ # thing here.
154
+ refusal?(result) ? refused(result) : result
155
+ rescue *authorization_errors => exception
156
+ refused(exception)
157
+ ensure
158
+ @_active_agent_tool_call = previous
159
+ end
160
+
161
+ private
162
+
163
+ def refusal?(value)
164
+ value.is_a?(Exception) && authorization_errors.any? { |klass| value.is_a?(klass) }
165
+ end
166
+
167
+ def refused(exception)
168
+ logger&.info("[#{self.class.name}] refused #{action_name}: #{exception.message}")
169
+ { error: exception.message }
170
+ end
171
+ end
172
+ end
@@ -110,9 +110,28 @@ module ActiveAgent
110
110
  class Agent
111
111
  # @param agent [Class] the agent class to proxy
112
112
  # @param params [Hash] the parameters to pass to agent instances
113
- def initialize(agent, params)
113
+ # @param actor [Object, nil] the caller the generation runs on behalf
114
+ # of (see ActiveAgent::Authorization). Kept beside params rather than
115
+ # in them: params reach templates and are part of what a generation
116
+ # is *about*, while the actor is who it is *for*, and only the caller
117
+ # may say.
118
+ def initialize(agent, params, actor: nil)
114
119
  @agent = agent
115
120
  @params = params
121
+ @actor = actor
122
+ end
123
+
124
+ # Adds parameters, keeping the actor. Lets +as+ and +with+ chain in
125
+ # either order.
126
+ # @return [ActiveAgent::Parameterized::Agent]
127
+ def with(params = {})
128
+ self.class.new(@agent, @params.merge(params), actor: @actor)
129
+ end
130
+
131
+ # Runs on behalf of +actor+, keeping any parameters.
132
+ # @return [ActiveAgent::Parameterized::Agent]
133
+ def as(actor)
134
+ self.class.new(@agent, @params, actor: actor)
116
135
  end
117
136
 
118
137
  # Intercepts calls to agent action methods and creates parameterized generations.
@@ -124,7 +143,9 @@ module ActiveAgent
124
143
  # @raise [NoMethodError] if the method doesn't exist on the agent class
125
144
  def method_missing(method_name, ...)
126
145
  if @agent.public_instance_methods.include?(method_name)
127
- ActiveAgent::Parameterized::Generation.new(@agent, method_name, @params, ...)
146
+ ActiveAgent::Parameterized::Generation.new(@agent, method_name, @params, ...).tap do |generation|
147
+ generation.actor = @actor
148
+ end
128
149
  else
129
150
  super
130
151
  end
@@ -146,6 +167,17 @@ module ActiveAgent
146
167
  #
147
168
  # @api private
148
169
  class Generation < ActiveAgent::Generation
170
+ # @param agent_class [Class] the agent class
171
+ # @param action [Symbol, String] the action method name
172
+ # @param params [Hash] the parameters to set on the agent instance
173
+ # @param args [Array] additional arguments for the action method
174
+ # The caller the generation runs on behalf of. Assigned after
175
+ # construction rather than taken as a keyword, because the action's own
176
+ # arguments are forwarded here and an actor keyword would collide with
177
+ # one of the same name.
178
+ # @return [Object, nil]
179
+ attr_accessor :actor
180
+
149
181
  # @param agent_class [Class] the agent class
150
182
  # @param action [Symbol, String] the action method name
151
183
  # @param params [Hash] the parameters to set on the agent instance
@@ -163,6 +195,7 @@ module ActiveAgent
163
195
  def agent
164
196
  @agent ||= agent_class.new.tap do |agent|
165
197
  agent.params = @params
198
+ agent.current_user = @actor
166
199
  agent.process(action_name, *args, **kwargs)
167
200
  end
168
201
  end
@@ -180,8 +213,12 @@ module ActiveAgent
180
213
  if processed?
181
214
  super
182
215
  else
216
+ # The actor rides as an ordinary job argument, which ActiveJob
217
+ # serializes through GlobalID like any other record, so a worker on
218
+ # another machine authorizes as the same caller.
183
219
  agent_class.generation_job.set(job_options).perform_later(
184
- agent_class.name, action_name.to_s, generation_method.to_s, params: @params, args: args, kwargs: kwargs
220
+ agent_class.name, action_name.to_s, generation_method.to_s,
221
+ params: @params, args: args, kwargs: kwargs, actor: @actor
185
222
  )
186
223
  end
187
224
  end