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 +4 -4
- data/CHANGELOG.md +266 -0
- data/lib/active_agent/base.rb +9 -0
- data/lib/active_agent/concerns/authorization.rb +172 -0
- data/lib/active_agent/concerns/parameterized.rb +40 -3
- data/lib/active_agent/concerns/release.rb +171 -0
- data/lib/active_agent/concerns/tooling.rb +3 -1
- data/lib/active_agent/delegation/runner.rb +17 -0
- data/lib/active_agent/evals/diagnosis.rb +62 -6
- data/lib/active_agent/evals/judge.rb +7 -2
- data/lib/active_agent/evals/model_spec.rb +27 -11
- data/lib/active_agent/evals/runner.rb +1 -1
- data/lib/active_agent/evals/scorer.rb +4 -1
- data/lib/active_agent/generation_job.rb +7 -1
- data/lib/active_agent/schema_tools.rb +173 -6
- data/lib/active_agent/telemetry/configuration.rb +11 -0
- data/lib/active_agent/telemetry/instrumentation.rb +9 -0
- data/lib/active_agent/telemetry/tracer.rb +4 -1
- data/lib/active_agent/telemetry.rb +22 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +3 -0
- data/lib/generators/active_agent/schema_tools/USAGE +22 -0
- data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +84 -0
- data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +48 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b14c71e437e0c5701b6300861adb5d8b79eba328602e255b38928e6030584a2b
|
|
4
|
+
data.tar.gz: c5eeeedce5730d88609b5131304571f9d7ecc12d3177ada460de7a4dd56ad61d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/lib/active_agent/base.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|