activeagent 1.5.0 → 1.6.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 +4 -4
- data/CHANGELOG.md +215 -0
- data/lib/active_agent/base.rb +7 -0
- data/lib/active_agent/concerns/authorization.rb +172 -0
- data/lib/active_agent/concerns/parameterized.rb +40 -3
- 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 +31 -1
- 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/railtie.rb +10 -0
- data/lib/active_agent/schema_tools.rb +503 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +5 -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: 1838cd05b12c4bc56330e8c4b4ec98295e885990bc67b600fefaa401f2543593
|
|
4
|
+
data.tar.gz: dcd3590de72aef1a8059a6fb03d9303672e73260a59c33e9244ccf326d48b7b6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1534a469b0eeec15765b71e7b43e8362660d9fc7610531c757acb882e130a95cc938a2033b2d0982233558123a0b162d6efb9c51da3e7038d0f0f1d6fcfe8d9e
|
|
7
|
+
data.tar.gz: eb849f183afd357f381836947891c8029d5ca4772f2e4bf1fda3fedb30ebb7f71db1021f9f94a8ba62337a99493b54b0124f9f0e28d8342fde4182d8ebf02411
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,221 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.6.0] - 2026-09-14
|
|
11
|
+
|
|
12
|
+
Releases `activeagent` and `actionagent` 1.6.0 from one tag.
|
|
13
|
+
|
|
14
|
+
A minor, not a patch. The cycle that began after 1.5.2 gives an agent a
|
|
15
|
+
caller — `current_user`, carried from whatever authenticated the call into
|
|
16
|
+
every `before_action`, every tool, every delegated sub-agent, every run over
|
|
17
|
+
MCP and every evaluation replay — so an authorization gem has something to
|
|
18
|
+
decide against. Around it: schema tools defined at runtime rather than only
|
|
19
|
+
in a file, a generator that writes the first one, those tools served
|
|
20
|
+
directly over MCP, and an evaluation that calls a fabricated answer a fault
|
|
21
|
+
instead of grading it as an honest gap. That is new public surface in both
|
|
22
|
+
gems, which is a minor under semver even though 1.5.2 shipped a feature as a
|
|
23
|
+
patch.
|
|
24
|
+
|
|
25
|
+
Two notes for upgrades. `tools_succeeded` is now awarded only for a tool the
|
|
26
|
+
scenario expected, so a suite that was quietly scoring wrong-tool runs as
|
|
27
|
+
partial successes will report lower — read the first run as a corrected
|
|
28
|
+
baseline. And `actor:` is now stripped from tool arguments and from
|
|
29
|
+
`params[params][actor]`: the caller is a property of the run, set once by
|
|
30
|
+
whatever authenticated it, and can no longer be named by the model or by a
|
|
31
|
+
client.
|
|
32
|
+
|
|
33
|
+
The engine's floor on the framework (`activeagent >= 1.4`) is unchanged and
|
|
34
|
+
still correct: 1.6.0 satisfies it.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **An evaluation replay runs as the evaluation's owner.** The scenario runner
|
|
39
|
+
handed `Agent#test_execute` no caller, so every tool a replay called ran
|
|
40
|
+
unattributed and a host scope answered empty — the suite graded an agent
|
|
41
|
+
that never saw a row. When agents are owned per user, a replay now runs as
|
|
42
|
+
the user who owns the evaluation, as a run over MCP runs as the key's
|
|
43
|
+
owner. A multi-tenant install still replays unattributed (an account is who
|
|
44
|
+
is billed, not who is allowed) unless a host adapter runs the suite itself.
|
|
45
|
+
- **The MCP facade serves the host's schema tools directly.** `tools/list`
|
|
46
|
+
at `POST <mount>/mcp` now offers every tool the dashboard's discovered
|
|
47
|
+
`ActiveAgent::SchemaTools` classes generate — `find_<records>`,
|
|
48
|
+
`count_<records>`, `get_<record>` — beside the `run_<slug>` agents, each
|
|
49
|
+
with its own parameter schema, and `tools/call` runs one as the key's
|
|
50
|
+
caller through the host's own scope, exactly as it would inside an agent
|
|
51
|
+
run. A client that only needs the rows no longer has to ask an agent for
|
|
52
|
+
them. A boundary violation is a tool result with `isError`, a refusal from
|
|
53
|
+
the host's scope is a JSON-RPC `-32003`, and neither the execution switch
|
|
54
|
+
nor the execution quota applies, because nothing generates. Set
|
|
55
|
+
`ActionAgent.mcp_schema_tools = false` to keep the tools reachable only
|
|
56
|
+
through agents. Closes #439.
|
|
57
|
+
- **A delegated run inherits its parent's caller.** `delegate_to` hands the
|
|
58
|
+
sub-agent the parent's `current_user` before its action runs, so its own
|
|
59
|
+
`before_action` callbacks and any scope its tools read through decide
|
|
60
|
+
against the same person. A parent authorized as one user no longer hands
|
|
61
|
+
its specialists an unattributed run — which a correctly written host scope
|
|
62
|
+
reads as "no access", a wrong answer wearing a right one's clothes. A
|
|
63
|
+
parent with no caller still delegates an unattributed run, never someone
|
|
64
|
+
else's.
|
|
65
|
+
- **`rails generate active_agent:schema_tools Reservation`** writes a starter
|
|
66
|
+
`ActiveAgent::SchemaTools` class under `app/agent_tools`. It exposes nothing
|
|
67
|
+
beyond `id` until a column is moved into `filterable` or `returns`; every
|
|
68
|
+
column the model has is listed, commented out, with its type, so the
|
|
69
|
+
allowlist is a review step rather than a blank page, and columns that look
|
|
70
|
+
like secrets are left off the list. Reads are scoped through
|
|
71
|
+
`<Model>Policy::Scope` when it exists (`--policy` / `--no-policy` decide
|
|
72
|
+
explicitly). This is #440's second option: the roster is still declared,
|
|
73
|
+
once, but the declaration is no longer written from scratch. (#440)
|
|
74
|
+
- **An agent knows who it is running for, so an authorization gem has
|
|
75
|
+
something to decide against.** `ActiveAgent::Base#current_user` carries the
|
|
76
|
+
caller, assigned by whatever authenticated the call
|
|
77
|
+
(`MyAgent.as(current_user).ask(...)`) and readable from every
|
|
78
|
+
`before_action`, so Pundit, CanCanCan or Action Policy authorize an agent
|
|
79
|
+
the way they authorize a controller. A refusal inside a tool call is
|
|
80
|
+
returned to the model as `{ error: ... }` so it can say it is not allowed;
|
|
81
|
+
a refusal anywhere else is raised to the caller. `denies_with` names a
|
|
82
|
+
gem's own error as a refusal.
|
|
83
|
+
- **The dashboard fills that seam.** A run records the caller that started it
|
|
84
|
+
(a Global ID, so a worker on another machine authorizes as the same
|
|
85
|
+
person), passes it to every tool as `actor:`, and runs the agent through
|
|
86
|
+
`as(actor)` — which is what a `SchemaTools` `scope` block has been waiting
|
|
87
|
+
for since 1.5.0. `ActionAgent.agent_actor_resolver` overrides who that is.
|
|
88
|
+
- **Agents reached over MCP run as the key's caller** rather than
|
|
89
|
+
unattributed, and an agent that refuses answers as a JSON-RPC error
|
|
90
|
+
(`-32003`) instead of as an empty result.
|
|
91
|
+
- **Schema tools can be defined at runtime.** `ActiveAgent::SchemaTools.define(Reservation,
|
|
92
|
+
filterable:, returns:, scope: | policy:)` builds the same bounded roster a
|
|
93
|
+
file under `app/agent_tools` would — same allowlists, same `call`, named
|
|
94
|
+
`ReservationTools` for logs — from a declaration held anywhere: a table, a
|
|
95
|
+
dashboard form, a test. The class is registered under its model and a
|
|
96
|
+
redefinition replaces the previous one, so a registry rebuilt on every
|
|
97
|
+
change holds one class per model; `undefine` drops it. The dashboard
|
|
98
|
+
discovers registry entries beside the files and lets a runtime definition
|
|
99
|
+
supersede a file for the same model. What is persisted, and where, stays the
|
|
100
|
+
host's decision; this is the seam a persisted declaration builds on. (#441)
|
|
101
|
+
- **A fabricated answer is now a fault.** `Diagnosis` raises `ungrounded_answer`
|
|
102
|
+
when an agent that had tools called none, did not say it could not answer,
|
|
103
|
+
and still stated specifics — a count, a record id, a date — that no tool
|
|
104
|
+
supplied. Where the scenario names an expected tool, `expected_tool_not_called`
|
|
105
|
+
says the same thing in its summary and carries `ungrounded: true`, so an
|
|
106
|
+
invented answer no longer reads like an honest gap. Both reach the judge,
|
|
107
|
+
which is what turns them into a suggested tool. An agent with no tools at all
|
|
108
|
+
is not flagged: it answers from its instructions by design, and whether that
|
|
109
|
+
is acceptable is the judge's grade, not a mechanical one. (#433)
|
|
110
|
+
|
|
111
|
+
### Changed
|
|
112
|
+
|
|
113
|
+
- **A wrong tool no longer outscores no tool.** `tools_succeeded` is awarded
|
|
114
|
+
only for a tool the scenario expected (or any tool when it expects none):
|
|
115
|
+
a tool that ran without erroring was evidence of the task only by accident,
|
|
116
|
+
and a scenario that called the wrong tool scored higher than one that called
|
|
117
|
+
nothing. (#433)
|
|
118
|
+
- **The judge reads more of a scenario's notes** — 1,500 characters rather
|
|
119
|
+
than 300 — because a suite's notes are often its rubric and the "must not"
|
|
120
|
+
clause tends to come last. (#433)
|
|
121
|
+
|
|
122
|
+
### Fixed
|
|
123
|
+
|
|
124
|
+
- **The caller can no longer be named by the model, or by the client.**
|
|
125
|
+
`actor:` reached `AgentToolbox.call` in the same keyword namespace as the
|
|
126
|
+
arguments a provider parsed out of a model's tool call, and
|
|
127
|
+
`params[params][actor]` in an execute request would have won over the
|
|
128
|
+
controller's own. Both are now stripped: the caller is a property of the
|
|
129
|
+
run, set once by whatever authenticated it. Scoped `SchemaTools` reads are
|
|
130
|
+
also excluded from the tool-result cache, so one caller's rows are never
|
|
131
|
+
replayed for the next.
|
|
132
|
+
- **A superseded runtime tool class is no longer offered twice.** Discovery
|
|
133
|
+
read every `SchemaTools` subclass out of `descendants`, where a class built
|
|
134
|
+
at runtime stays until it is collected, so rebuilding a model's tools
|
|
135
|
+
accumulated stale duplicates. Runtime-built classes are now read from the
|
|
136
|
+
registry only. (#441)
|
|
137
|
+
- **A persisted model selection re-runs under the provider it ran under.**
|
|
138
|
+
`Evals::ModelSpec.parse_all` re-parsed a round-tripped spec from its label,
|
|
139
|
+
so `anthropic/claude-sonnet-4.5` run through OpenRouter came back as
|
|
140
|
+
Anthropic's own `claude-sonnet-4.5` as soon as that provider's gem was
|
|
141
|
+
installed — and the re-run failed for want of an Anthropic credential. A
|
|
142
|
+
hash naming both `provider` and `model` is now rebuilt as it was; a bare
|
|
143
|
+
label is still parsed. The dashboard's "re-run" of a saved selection is
|
|
144
|
+
the path this fixes.
|
|
145
|
+
|
|
146
|
+
## [1.5.2] - 2026-09-11
|
|
147
|
+
|
|
148
|
+
Releases `activeagent` and `actionagent` 1.5.2 from one tag.
|
|
149
|
+
|
|
150
|
+
**1.5.1 was never tagged.** Its version bump reached `main`, but three PRs
|
|
151
|
+
that change `actionagent` merged alongside it, and that release deliberately
|
|
152
|
+
held `actionagent` at 1.5.0 — publishing it would have shipped the fix below
|
|
153
|
+
while leaving every dashboard change of this cycle unpublished, because
|
|
154
|
+
`release.yml` skips a version already on RubyGems. 1.5.2 supersedes it and
|
|
155
|
+
carries both gems. The 1.5.1 notes are kept below as the record of what that
|
|
156
|
+
bump contained.
|
|
157
|
+
|
|
158
|
+
### Added
|
|
159
|
+
|
|
160
|
+
- **Schema-derived agent tools.** `ActiveAgent::SchemaTools` turns an
|
|
161
|
+
ActiveRecord model plus a declared boundary into a bounded, enumerable tool
|
|
162
|
+
roster — `find_*`, `count_*`, `get_*` — with `filterable` and `returns`
|
|
163
|
+
allowlists. An undeclared column is rejected rather than silently dropped:
|
|
164
|
+
ignoring an unknown filter answers a broader question than was asked while
|
|
165
|
+
still looking like success. Results are capped (25 default, 100 max) with a
|
|
166
|
+
truncation marker the model can see. (#435)
|
|
167
|
+
|
|
168
|
+
- **Host tools reach the dashboard.** `ActionAgent.schema_tools` offers each
|
|
169
|
+
generated tool beside `AgentToolbox`'s built-ins: individually selectable in
|
|
170
|
+
the agent editor, dispatched by name at execution, and nameable in an
|
|
171
|
+
evaluation's `tools:` expectation. Previously a declared schema tool was
|
|
172
|
+
invisible — `definitions_for` returned nothing, the model received no
|
|
173
|
+
schemas and invented tool names in prose while the run scored 0.0 for what
|
|
174
|
+
looked like a model failure. (#435, closes #438)
|
|
175
|
+
|
|
176
|
+
- **Tools are discovered, not declared twice.** Leave `schema_tools` unset and
|
|
177
|
+
every subclass under `schema_tools_path` (`app/agent_tools`) is offered.
|
|
178
|
+
Adding a tool is adding a file. Anonymous classes are excluded from
|
|
179
|
+
discovery: a runtime-built class cannot supersede itself, so it would
|
|
180
|
+
accumulate one per reload. (#435, refs #440)
|
|
181
|
+
|
|
182
|
+
- **`scope_by_policy`** resolves a model's policy by name —
|
|
183
|
+
`Reservation` → `ReservationPolicy::Scope` — instead of hand-writing the
|
|
184
|
+
block. Opt-in, because silently scoping a class that declared none would
|
|
185
|
+
change what an existing tool returns; a missing policy raises at declaration
|
|
186
|
+
rather than quietly reading the whole table. (#435)
|
|
187
|
+
|
|
188
|
+
- **A model's agent starts with that model's tools.** `ReservationAgent` is
|
|
189
|
+
seeded from `ReservationTools` on create. A default, never a restriction:
|
|
190
|
+
any agent may enable any tool, and an explicit selection — including a
|
|
191
|
+
deliberate empty one — is never overwritten. (#435)
|
|
192
|
+
|
|
193
|
+
### Fixed
|
|
194
|
+
|
|
195
|
+
- **MCP tool-discovery failures are reported instead of running tool-less and
|
|
196
|
+
silent.** `MCPToolDispatcher#tool_definitions` rescued a failed `tools/list`
|
|
197
|
+
to `[]`, so a server that 401s and one that legitimately serves no tools
|
|
198
|
+
were indistinguishable: the agent ran without tools, the model fabricated,
|
|
199
|
+
and the report offered prompt advice for what was a transport failure.
|
|
200
|
+
`discovery_errors` now names the server, its URL and the underlying error,
|
|
201
|
+
and `all_servers_failed?` lets a caller fail loudly rather than grade an
|
|
202
|
+
invented answer. (#434, closes #425)
|
|
203
|
+
|
|
204
|
+
- **Nil VCR filters no longer flake replays**, and the MCP plural is spelled
|
|
205
|
+
correctly. (#436)
|
|
206
|
+
|
|
207
|
+
## [1.5.1] - 2026-09-11 [UNRELEASED — superseded by 1.5.2]
|
|
208
|
+
|
|
209
|
+
Bumped `activeagent` to 1.5.1 and held `actionagent` at 1.5.0. Never tagged;
|
|
210
|
+
its contents ship in 1.5.2.
|
|
211
|
+
|
|
212
|
+
### Fixed
|
|
213
|
+
|
|
214
|
+
- **A spec hash names its model rather than reaching the provider as an
|
|
215
|
+
inspected Hash.** `Evals::ModelSpec.parse_all` called `to_s` on each value,
|
|
216
|
+
so a Hash travelled as the model ID and the provider answered
|
|
217
|
+
`{"label" => "openrouter/openai/gpt-4o-mini", ...} is not a valid model ID`
|
|
218
|
+
— every scenario of the run failing before it reached the model. A caller
|
|
219
|
+
passing a plain string was unaffected, which is why a single run worked
|
|
220
|
+
while a whole suite failed. The path is reachable by design rather than by
|
|
221
|
+
misuse: a run persists its models as `specs.map(&:to_h)`, so re-running that
|
|
222
|
+
selection hands the hashes back. `parse_all` now reads a hash's `label`,
|
|
223
|
+
then its `model`, and leaves strings alone.
|
|
224
|
+
|
|
10
225
|
## [1.5.0] - 2026-09-10
|
|
11
226
|
|
|
12
227
|
Releases `activeagent` 1.5.0 and `actionagent` 1.5.0 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"
|
|
@@ -43,6 +44,7 @@ module ActiveAgent
|
|
|
43
44
|
include AbstractController::Caching
|
|
44
45
|
|
|
45
46
|
include Callbacks
|
|
47
|
+
# After Rescue: the refusal handler is registered with rescue_from.
|
|
46
48
|
include Delegation
|
|
47
49
|
include Parameterized
|
|
48
50
|
include Provider
|
|
@@ -52,6 +54,11 @@ module ActiveAgent
|
|
|
52
54
|
include Tooling
|
|
53
55
|
include View
|
|
54
56
|
|
|
57
|
+
# Last of the behaviour concerns: its rescue_from must sit on top of the
|
|
58
|
+
# handlers an agent registers for its own authorization gem, so a host
|
|
59
|
+
# mapping Pundit::NotAuthorizedError keeps deciding what a refusal means.
|
|
60
|
+
include Authorization
|
|
61
|
+
|
|
55
62
|
include Observers
|
|
56
63
|
include Previews
|
|
57
64
|
|
|
@@ -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
|
|
@@ -16,7 +16,9 @@ module ActiveAgent
|
|
|
16
16
|
# @return [Proc] callback proc that accepts (action_name, *args, **kwargs)
|
|
17
17
|
def tools_function
|
|
18
18
|
proc do |action_name, *args, **kwargs|
|
|
19
|
-
|
|
19
|
+
# Marked as a tool call so a refusal is reported to the model as a
|
|
20
|
+
# result rather than raised through the run (see Authorization).
|
|
21
|
+
with_tool_call { process(action_name, *args, **kwargs) }
|
|
20
22
|
end
|
|
21
23
|
end
|
|
22
24
|
end
|
|
@@ -117,6 +117,7 @@ module ActiveAgent
|
|
|
117
117
|
def generate(arguments)
|
|
118
118
|
agent = definition.resolved_agent_class.new
|
|
119
119
|
agent.params = resolved_params(arguments)
|
|
120
|
+
inherit_actor(agent)
|
|
120
121
|
agent.process(definition.action, **arguments)
|
|
121
122
|
|
|
122
123
|
definition.backend.apply(agent)
|
|
@@ -126,6 +127,22 @@ module ActiveAgent
|
|
|
126
127
|
agent.process_prompt
|
|
127
128
|
end
|
|
128
129
|
|
|
130
|
+
# A delegated generation runs on behalf of whoever the parent runs for.
|
|
131
|
+
# The sub-agent gets the parent's caller before its action runs, so its
|
|
132
|
+
# own before_action callbacks and any scope its tools read through decide
|
|
133
|
+
# against the same person — a parent authorized as one user must not
|
|
134
|
+
# hand its specialists an unattributed run, which a correctly written
|
|
135
|
+
# host scope reads as "no access". Hosts on an older framework, where an
|
|
136
|
+
# agent has no caller to carry, are left as they were.
|
|
137
|
+
#
|
|
138
|
+
# @param agent [ActiveAgent::Base]
|
|
139
|
+
# @return [void]
|
|
140
|
+
def inherit_actor(agent)
|
|
141
|
+
return unless owner.respond_to?(:current_user) && agent.respond_to?(:current_user=)
|
|
142
|
+
|
|
143
|
+
agent.current_user = owner.current_user
|
|
144
|
+
end
|
|
145
|
+
|
|
129
146
|
# A delegated generation is part of its parent's work, so it carries the
|
|
130
147
|
# parent's trace id — otherwise the sub-agent's tokens and latency land
|
|
131
148
|
# in a separate trace and the budget you set has nothing to show for it.
|
|
@@ -12,6 +12,7 @@ module ActiveAgent
|
|
|
12
12
|
# tool_error — a tool the agent called returned an error
|
|
13
13
|
# missing_capability — the agent said no tool covers the task
|
|
14
14
|
# expected_tool_not_called — the scenario expects a tool the agent did not call
|
|
15
|
+
# ungrounded_answer — the answer states specifics no tool call supplied
|
|
15
16
|
# forbidden_content — the answer contains a pattern the scenario forbids
|
|
16
17
|
# missing_content — the answer lacks a pattern the scenario expects
|
|
17
18
|
# low_quality — the answer scored below the threshold
|
|
@@ -21,7 +22,7 @@ module ActiveAgent
|
|
|
21
22
|
# Returns nil for a passing result.
|
|
22
23
|
class Diagnosis
|
|
23
24
|
FAULTS = %w[
|
|
24
|
-
run_error tool_error missing_capability expected_tool_not_called
|
|
25
|
+
run_error tool_error missing_capability expected_tool_not_called ungrounded_answer
|
|
25
26
|
forbidden_content missing_content low_quality judge_unavailable
|
|
26
27
|
].freeze
|
|
27
28
|
|
|
@@ -38,6 +39,19 @@ module ActiveAgent
|
|
|
38
39
|
/\bcan(?:'|no)t (?:be )?(?:done|determined|answered) with (?:the|my) (?:current|available) tools\b/i
|
|
39
40
|
].freeze
|
|
40
41
|
|
|
42
|
+
# Phrasings that state a specific fact — a record id, a date, a count of
|
|
43
|
+
# things — which an agent that called no tool can only have invented.
|
|
44
|
+
# Deliberately narrow: a number inside prose ("here are three options",
|
|
45
|
+
# "within 30 days") is not a claim about data, and a false positive here
|
|
46
|
+
# fails a scenario that may have passed on its merits.
|
|
47
|
+
SPECIFIC_CLAIMS = [
|
|
48
|
+
/#\d+\b/,
|
|
49
|
+
/\b\d{4}-\d{2}-\d{2}\b/,
|
|
50
|
+
/\b(?:you have|there are|there is|we have|I found|found|showing|a total of)\s+(?:\*\*)?\d+\b/i,
|
|
51
|
+
/\b\d+\s+(?:\*\*)?(?:open|overdue|pending|active|closed|resolved|completed|unpaid|outstanding|new|matching|
|
|
52
|
+
records?|results?|rows?|entries|items?|tickets?|orders?|tasks?|issues?|invoices?|customers?|users?|milestones?)\b/ix
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
41
55
|
Result = Struct.new(:fault, :summary, :recommendation, :evidence, keyword_init: true) do
|
|
42
56
|
def to_h
|
|
43
57
|
{
|
|
@@ -76,7 +90,7 @@ module ActiveAgent
|
|
|
76
90
|
end
|
|
77
91
|
|
|
78
92
|
def call
|
|
79
|
-
run_error || tool_error || missing_capability || expected_tool_not_called ||
|
|
93
|
+
run_error || tool_error || missing_capability || expected_tool_not_called || ungrounded_answer ||
|
|
80
94
|
forbidden_content || missing_content || low_quality
|
|
81
95
|
end
|
|
82
96
|
|
|
@@ -178,16 +192,58 @@ module ActiveAgent
|
|
|
178
192
|
"#{agent} answered with #{called_tools.uniq.join(', ')} instead of #{expected.join(', ')}. Sharpen " \
|
|
179
193
|
"both tools' descriptions so the model can tell them apart, or say in the instructions which tool " \
|
|
180
194
|
"answers this kind of task."
|
|
195
|
+
elsif asserts_specifics?
|
|
196
|
+
"#{expected.join(', ')} is available but #{agent.downcase} answered without calling any tool and " \
|
|
197
|
+
"stated specifics it could not have looked up (\"#{claim_excerpt}\"). Treat the answer as invented: " \
|
|
198
|
+
"instruct it to answer this kind of task only from a tool result, and to say so when it has none."
|
|
181
199
|
else
|
|
182
200
|
"#{expected.join(', ')} is available but #{agent.downcase} answered without calling any tool. Tell " \
|
|
183
201
|
"it in the instructions to prefer tool-backed answers for this kind of task, and check the tool's " \
|
|
184
202
|
"description says what it returns."
|
|
185
203
|
end
|
|
186
204
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
205
|
+
summary = "Expected #{expected.join(' or ')} to be called; #{agent.downcase} called " \
|
|
206
|
+
"#{called_tools.uniq.presence&.join(', ') || 'nothing'}"
|
|
207
|
+
summary += " and answered with specifics no tool supplied" if called_tools.empty? && asserts_specifics?
|
|
208
|
+
|
|
209
|
+
result("expected_tool_not_called", "#{summary}.", recommendation,
|
|
210
|
+
"expected" => expected, "called" => called_tools, "unavailable" => unavailable,
|
|
211
|
+
"ungrounded" => (called_tools.empty? && asserts_specifics?) || nil, "claim" => (claim_excerpt if called_tools.empty?))
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The answer states specifics — a count, an id, a date — that no tool
|
|
215
|
+
# call could have supplied. Reached only when the scenario names no
|
|
216
|
+
# expected tool (expected_tool_not_called reports the same fabrication
|
|
217
|
+
# otherwise) and only for an agent that had tools to call: one with
|
|
218
|
+
# none answers from its instructions by design, and whether that is
|
|
219
|
+
# acceptable is the judge's call, not a mechanical one.
|
|
220
|
+
def ungrounded_answer
|
|
221
|
+
return nil if @available_tools.empty? || called_tools.any?
|
|
222
|
+
return nil unless asserts_specifics?
|
|
223
|
+
|
|
224
|
+
result("ungrounded_answer",
|
|
225
|
+
"#{agent} stated specifics (\"#{claim_excerpt}\") without calling any tool that could have supplied them.",
|
|
226
|
+
"Nothing in the answer came from a tool, so the figures in it are invented. Tell #{agent.downcase} in its " \
|
|
227
|
+
"instructions to answer this kind of task only from a tool result and to say when it has none; if none of " \
|
|
228
|
+
"#{@available_tools.join(', ')} returns this data, add a tool that does.",
|
|
229
|
+
"claim" => claim_excerpt, "tools_available" => @available_tools)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def asserts_specifics?
|
|
233
|
+
specific_claim.present?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def specific_claim
|
|
237
|
+
return @specific_claim if defined?(@specific_claim)
|
|
238
|
+
|
|
239
|
+
@specific_claim = SPECIFIC_CLAIMS.lazy.filter_map { |pattern| answer.match(pattern) }.first
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def claim_excerpt
|
|
243
|
+
match = specific_claim
|
|
244
|
+
return nil unless match
|
|
245
|
+
|
|
246
|
+
answer[[ match.begin(0) - 40, 0 ].max, 120].to_s.strip
|
|
191
247
|
end
|
|
192
248
|
|
|
193
249
|
def forbidden_content
|