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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7401cdff6a7895238a082383f2ef32683b743946445e11dacbf377470bf31071
4
- data.tar.gz: b0f847d04eedf620c7f5320950d65e5ffc712296ef7db2b7ea6be25f3bc433b7
3
+ metadata.gz: 1838cd05b12c4bc56330e8c4b4ec98295e885990bc67b600fefaa401f2543593
4
+ data.tar.gz: dcd3590de72aef1a8059a6fb03d9303672e73260a59c33e9244ccf326d48b7b6
5
5
  SHA512:
6
- metadata.gz: 0a29cb99c3139f9c264eb6e86677d47ed592fb165c39489be2efc8ea2bb2dd1614bc8802643b812b04319b7356bc37177943bc9c2e0174de276555ba811ba426
7
- data.tar.gz: 6fb392e7cb2f4381d79ae16567edca55d62c585b06f04a6a3da6b31cd0bb7e6149b2e67ceb7e5e143b04bc41123dd1d5bba14f38b33be6e21813e9923308d524
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.
@@ -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
- 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
@@ -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
- process(action_name, *args, **kwargs)
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
- result("expected_tool_not_called",
188
- "Expected #{expected.join(' or ')} to be called; #{agent.downcase} called " \
189
- "#{called_tools.uniq.presence&.join(', ') || 'nothing'}.",
190
- recommendation, "expected" => expected, "called" => called_tools, "unavailable" => unavailable)
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