activeagent 1.5.2 → 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: 73e63c87b700b6dec4e3410ae0547ff1c02714fbe79b24175447fb5638a9fe3b
4
- data.tar.gz: ef361dc3bb7dcab252c0fadf1fb526e689b97b21386a2d50b6ba6eb96a84100a
3
+ metadata.gz: 1838cd05b12c4bc56330e8c4b4ec98295e885990bc67b600fefaa401f2543593
4
+ data.tar.gz: dcd3590de72aef1a8059a6fb03d9303672e73260a59c33e9244ccf326d48b7b6
5
5
  SHA512:
6
- metadata.gz: 7ed21c4a284e17d0014ef83b09fc56741b96cbb83f219f38dc3e8ad87f511f9ed5b3d902e1b1e9c7e4db11c83bce07ed0effbb2f44d85c35d0da4738598744b3
7
- data.tar.gz: c3634cfbe8e532889e2ef82b7080b30f0233a0faf1e81b5c37995949a8c7a6d5e6ebf9e3800e39457c54de3803d22b3545d4606d448867a777915cabf1a39b15
6
+ metadata.gz: 1534a469b0eeec15765b71e7b43e8362660d9fc7610531c757acb882e130a95cc938a2033b2d0982233558123a0b162d6efb9c51da3e7038d0f0f1d6fcfe8d9e
7
+ data.tar.gz: eb849f183afd357f381836947891c8029d5ca4772f2e4bf1fda3fedb30ebb7f71db1021f9f94a8ba62337a99493b54b0124f9f0e28d8342fde4182d8ebf02411
data/CHANGELOG.md CHANGED
@@ -7,6 +7,142 @@ 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
+
10
146
  ## [1.5.2] - 2026-09-11
11
147
 
12
148
  Releases `activeagent` and `actionagent` 1.5.2 from one tag.
@@ -4,6 +4,7 @@ require "active_support/core_ext/hash/except"
4
4
  require "active_support/core_ext/module/anonymous"
5
5
  require "active_support/core_ext/string/inflections"
6
6
 
7
+ require "active_agent/concerns/authorization"
7
8
  require "active_agent/concerns/callbacks"
8
9
  require "active_agent/concerns/delegation"
9
10
  require "active_agent/concerns/observers"
@@ -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
@@ -24,6 +24,11 @@ module ActiveAgent
24
24
  # @yieldparam instructions [String] the system prompt
25
25
  # @yieldparam prompt [String] the user prompt
26
26
  # @yieldreturn [String] the completion text
27
+ # How much of a scenario's notes the judge reads. Where a suite's notes
28
+ # are its grading rubric, a "Must not…" clause tends to come last, and a
29
+ # judge that never saw it recommends against it.
30
+ NOTES_LIMIT = 1_500
31
+
27
32
  def initialize(label:, &generate)
28
33
  raise ArgumentError, "Judge.new needs a block that returns the model's completion" unless generate
29
34
 
@@ -64,7 +69,7 @@ module ActiveAgent
64
69
  ---
65
70
  #{scenario.prompt}
66
71
  ---
67
- #{"Context for the evaluator: #{scenario.notes.truncate(300)}\n" if scenario.notes.present?}
72
+ #{"Context for the evaluator: #{scenario.notes.truncate(NOTES_LIMIT)}\n" if scenario.notes.present?}
68
73
  The assistant answered:
69
74
  ---
70
75
  #{answer.to_s.truncate(4_000)}
@@ -99,7 +104,7 @@ module ActiveAgent
99
104
  Scenario (the user's message):
100
105
  #{scenario.prompt}
101
106
  #{"Expected tools: #{scenario.expected_tools.join(', ')}" if scenario.expected_tools.any?}
102
- #{"Notes: #{scenario.notes.truncate(300)}" if scenario.notes.present?}
107
+ #{"Notes: #{scenario.notes.truncate(NOTES_LIMIT)}" if scenario.notes.present?}
103
108
 
104
109
  Tools the agent called:
105
110
  #{calls.presence || '(none)'}
@@ -46,22 +46,38 @@ module ActiveAgent
46
46
  # duplicates by label.
47
47
  def self.parse_all(values, **options)
48
48
  values = values.to_s.split(",") unless values.is_a?(Array)
49
- values.map { |value| coerce(value) }.reject(&:blank?).uniq.map { |value| parse(value, **options) }
49
+ values.filter_map { |value| from_value(value, **options) }.uniq(&:label)
50
50
  end
51
51
 
52
- # The label of one requested model. A caller that round-trips a spec
53
- # the dashboard persists `specs.map(&:to_h)` and hands it back on a
54
- # re-run sends a Hash, whose `to_s` is the inspected Hash and reaches
55
- # the provider as the model ID: `{"label" => "openrouter/gpt-4o-mini",
56
- # ...} is not a valid model ID`.
52
+ # One requested model, from the text a user typed or from a spec handed
53
+ # back whole. The dashboard persists `specs.map(&:to_h)` and returns it on
54
+ # a re-run, so a value may be a Hash: one that names both `provider` and
55
+ # `model` is rebuilt exactly as it ran, because re-parsing its label
56
+ # would route a vendor-prefixed model the wrong way —
57
+ # `"anthropic/claude-sonnet-4.5"` run through OpenRouter came back as
58
+ # Anthropic's own `claude-sonnet-4.5` the moment that provider was
59
+ # installed. A Hash naming only a label or a model is parsed from that
60
+ # text; anything naming nothing is dropped.
57
61
  #
58
- # @return [String, nil] nil when the value names no model
59
- def self.coerce(value)
60
- return value.to_h.stringify_keys.values_at("label", "model").compact.first.to_s.strip if value.respond_to?(:to_h) && !value.is_a?(String)
62
+ # @return [ModelSpec, nil]
63
+ def self.from_value(value, **options)
64
+ text =
65
+ if value.respond_to?(:to_h) && !value.is_a?(String)
66
+ hash = value.to_h.stringify_keys
67
+ if hash["provider"].present? && hash["model"].present?
68
+ return new(label: hash["label"].presence || hash["model"], provider: hash["provider"], model: hash["model"])
69
+ end
61
70
 
62
- value.to_s.strip
71
+ hash.values_at("label", "model").compact.first.to_s.strip
72
+ else
73
+ value.to_s.strip
74
+ end
75
+
76
+ return nil if text.blank?
77
+
78
+ parse(text, **options)
63
79
  end
64
- private_class_method :coerce
80
+ private_class_method :from_value
65
81
 
66
82
  # The provider a bare model name runs under. A rule whose provider the
67
83
  # caller does not offer is skipped, so an app without Ollama does not
@@ -28,7 +28,7 @@ module ActiveAgent
28
28
  class Runner
29
29
  # Faults where a judge can add something the evidence alone cannot: what
30
30
  # tool to add, or how to change the instructions.
31
- DEFAULT_REFINE_FAULTS = %w[missing_capability expected_tool_not_called low_quality missing_content].freeze
31
+ DEFAULT_REFINE_FAULTS = %w[missing_capability expected_tool_not_called ungrounded_answer low_quality missing_content].freeze
32
32
  DEFAULT_JUDGE_LIMIT = 25
33
33
 
34
34
  attr_reader :scenarios, :models, :criteria, :judge, :threshold
@@ -49,7 +49,10 @@ module ActiveAgent
49
49
  hit = scenario.forbidden_patterns.any? { |pattern| self.class.matches_pattern?(answer, pattern) }
50
50
  scores["forbidden_content"] = hit ? 0.0 : 1.0
51
51
  end
52
- if replay.tool_calls.any?
52
+ # A tool that ran without erroring is evidence only when it is one the
53
+ # scenario expected: a wrong tool that succeeded used to outscore
54
+ # calling nothing at all.
55
+ if replay.tool_calls.any? && (scenario.expected_tools.empty? || (scenario.expected_tools & replay.tool_names).any?)
53
56
  scores["tools_succeeded"] = replay.failed_tool_calls.any? ? 0.0 : 1.0
54
57
  end
55
58
 
@@ -18,8 +18,14 @@ module ActiveAgent
18
18
 
19
19
  rescue_from StandardError, with: :handle_exception_with_agent_class
20
20
 
21
- def perform(agent, agent_method, generation_method, args:, kwargs: nil, params: nil)
21
+ # +actor+ is the caller the generation runs on behalf of
22
+ # (ActiveAgent::Authorization). ActiveJob serializes it like any other
23
+ # argument, so a record arrives as the same record the caller passed and
24
+ # the agent's authorization callbacks decide against a real user rather
25
+ # than against nil.
26
+ def perform(agent, agent_method, generation_method, args:, kwargs: nil, params: nil, actor: nil)
22
27
  agent_class = params ? agent.constantize.with(params) : agent.constantize
28
+ agent_class = agent_class.as(actor) if actor
23
29
  prompt = if kwargs
24
30
  agent_class.public_send(agent_method, *args, **kwargs)
25
31
  else
@@ -168,6 +168,71 @@ module ActiveAgent
168
168
  @scope = block
169
169
  end
170
170
 
171
+ # Runtime-defined tool classes, keyed by model name. A definition built
172
+ # by {.define} replaces the previous one for its model, so a registry
173
+ # that is rebuilt on every change — from a table, from a dashboard
174
+ # edit — holds one class per model rather than one per rebuild.
175
+ #
176
+ # @return [Hash{String => Class}]
177
+ def registry
178
+ @registry ||= {}
179
+ end
180
+
181
+ # Builds a tool class from a declaration rather than from a file:
182
+ #
183
+ # ActiveAgent::SchemaTools.define(Reservation,
184
+ # filterable: %i[status guest_id],
185
+ # returns: %i[id status guest_id arrives_on],
186
+ # policy: true) # ReservationPolicy::Scope, as scope_by_policy would
187
+ #
188
+ # The class behaves exactly as a file-defined one — same roster, same
189
+ # allowlists, same +call+ — and is named "<Model>Tools" for logs and
190
+ # telemetry. It is marked runtime-built so discovery does not read it
191
+ # back out of +descendants+ (where every class ever built stays until
192
+ # collected), and registered under its model, replacing whatever the
193
+ # registry held: that is what keeps a rebuild from accumulating classes
194
+ # (#441). +scope:+ takes a lambda or proc receiving the actor; +policy:+
195
+ # resolves the model's policy by name; neither means unscoped, as for a
196
+ # file-defined class.
197
+ #
198
+ # @param model [Class] an ActiveRecord class
199
+ # @param filterable [Array<Symbol, String>]
200
+ # @param returns [Array<Symbol, String>]
201
+ # @param scope [Proc, nil]
202
+ # @param policy [Boolean, Class] true for the conventional policy, or the policy class
203
+ # @param name [String, nil] the class name, "<Model>Tools" by default
204
+ # @return [Class]
205
+ def define(model, filterable: [], returns: [], scope: nil, policy: false, name: nil)
206
+ klass = Class.new(self)
207
+ klass.instance_variable_set(:@runtime, true)
208
+ class_name = name || "#{model.name}Tools"
209
+ klass.define_singleton_method(:name) { class_name }
210
+ klass.model(model)
211
+ klass.filterable(*filterable) if filterable.present?
212
+ klass.returns(*returns) if returns.present?
213
+ klass.scope_by_policy(policy == true ? nil : policy) if policy
214
+ klass.scope(&scope) if scope
215
+
216
+ registry[model.name] = klass
217
+ end
218
+
219
+ # Drops the runtime definition for +model+; discovery stops offering it.
220
+ #
221
+ # @param model [Class, String]
222
+ # @return [Class, nil] the class that was registered
223
+ def undefine(model)
224
+ registry.delete(model.respond_to?(:name) ? model.name : model.to_s)
225
+ end
226
+
227
+ # Whether this class was built by {.define} rather than loaded from a
228
+ # file. Discovery reads runtime classes from {.registry}, never from
229
+ # +descendants+, so a superseded one is not offered twice.
230
+ #
231
+ # @return [Boolean]
232
+ def runtime?
233
+ @runtime == true
234
+ end
235
+
171
236
  # The full, fixed tool roster in provider function-calling format.
172
237
  #
173
238
  # @return [Array<Hash>] tool definitions with :name, :description, :parameters
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.5.2"
2
+ VERSION = "1.6.0"
3
3
  end
data/lib/active_agent.rb CHANGED
@@ -92,6 +92,9 @@ module ActiveAgent
92
92
  #
93
93
  # These components are loaded on-demand when first referenced.
94
94
  autoload :Base
95
+ # The refusal an agent raises, and the default in Base.authorization_errors.
96
+ # Reachable before any agent class has loaded, so the dashboard can name it.
97
+ autoload :NotAuthorized, "active_agent/concerns/authorization"
95
98
  autoload :Callbacks, "active_agent/concerns/callbacks"
96
99
  autoload :Delegation, "active_agent/concerns/delegation"
97
100
  autoload :Streaming, "active_agent/concerns/streaming"
@@ -0,0 +1,22 @@
1
+ Description:
2
+ Writes a starter ActiveAgent::SchemaTools class for a model under
3
+ app/agent_tools. The class generates find_<records>, count_<records> and
4
+ get_<record> tools, and exposes nothing beyond `id` until you move a
5
+ column into `filterable` or `returns` — every column the model has is
6
+ listed, commented out, so the allowlist is a review step rather than a
7
+ blank page. Columns that look like secrets are left out of the list.
8
+
9
+ When <Model>Policy::Scope exists the class scopes every read through it
10
+ (`scope_by_policy`); otherwise a `scope` block is suggested. Pass
11
+ --policy or --no-policy to decide explicitly.
12
+
13
+ Examples:
14
+ `bin/rails generate active_agent:schema_tools Reservation`
15
+
16
+ creates:
17
+ app/agent_tools/reservation_tools.rb
18
+
19
+ `bin/rails generate active_agent:schema_tools Reservation --policy`
20
+
21
+ scopes reads through ReservationPolicy::Scope even if it cannot be
22
+ found from here.
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Generators
5
+ # Writes a starter ActiveAgent::SchemaTools class for one model:
6
+ #
7
+ # bin/rails generate active_agent:schema_tools Reservation
8
+ #
9
+ # The class it writes exposes nothing beyond +id+ until someone uncomments
10
+ # a column, because which columns an agent may filter on and read back is
11
+ # a judgement about exposure, not a fact about the table (#440). Every
12
+ # column the model has is listed, commented out, minus the ones that look
13
+ # like secrets, so the allowlist is a review step rather than a blank page.
14
+ class SchemaToolsGenerator < ::Rails::Generators::NamedBase
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ # Columns never suggested, whatever the model: reading one back would
18
+ # hand a model a credential, and filtering on one leaks it a character
19
+ # at a time through the row counts.
20
+ SECRET_COLUMNS = /password|digest|token|secret|api_key|otp|encrypted|ssn/i
21
+
22
+ class_option :policy, type: :boolean, default: nil,
23
+ desc: "Scope every read through <Model>Policy::Scope (default: when that policy exists)"
24
+
25
+ check_class_collision suffix: "Tools"
26
+
27
+ def create_tools_file
28
+ template "schema_tools.rb.tt", File.join("app/agent_tools", class_path, "#{file_name}_tools.rb")
29
+ end
30
+
31
+ private
32
+
33
+ # "Reservation" and "ReservationTools" both name the Reservation model.
34
+ def file_name # :doc:
35
+ @_file_name ||= super.sub(/_tools\z/i, "")
36
+ end
37
+
38
+ def model_class
39
+ @model_class ||= class_name.safe_constantize
40
+ end
41
+
42
+ def policy_class_name
43
+ "#{class_name}Policy"
44
+ end
45
+
46
+ def policy?
47
+ return options[:policy] unless options[:policy].nil?
48
+
49
+ "#{policy_class_name}::Scope".safe_constantize.present?
50
+ end
51
+
52
+ # [name, type] for every column the model has, or [] when the model or
53
+ # its table cannot be read from here (a model that does not exist yet,
54
+ # or a database that is not set up) — the file is still written.
55
+ def columns
56
+ @columns ||= begin
57
+ if model_class.respond_to?(:columns) && model_class.respond_to?(:table_exists?) && model_class.table_exists?
58
+ model_class.columns.map { |column| [ column.name, column.type ] }
59
+ else
60
+ []
61
+ end
62
+ rescue StandardError
63
+ []
64
+ end
65
+ end
66
+
67
+ def suggested_columns
68
+ columns.reject { |name, _type| name == "id" || name.match?(SECRET_COLUMNS) }
69
+ end
70
+
71
+ def secret_columns
72
+ columns.select { |name, _type| name.match?(SECRET_COLUMNS) }.map(&:first)
73
+ end
74
+
75
+ def collection_name
76
+ class_name.demodulize.underscore.pluralize
77
+ end
78
+
79
+ def resource_name
80
+ class_name.demodulize.underscore
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,48 @@
1
+ <% module_namespacing do -%>
2
+ # Bounded, read-only agent tools over <%= class_name %>:
3
+ # find_<%= collection_name %>, count_<%= collection_name %>, get_<%= resource_name %>.
4
+ #
5
+ # Nothing is exposed until you say so. Move a column from the commented lists
6
+ # into `filterable` (an agent may filter on it) or `returns` (an agent may
7
+ # read it back). Each one is a judgement about exposure, not a fact about the
8
+ # table: leave out anything a model should never see.
9
+ class <%= class_name %>Tools < ActiveAgent::SchemaTools
10
+ model <%= class_name %>
11
+
12
+ filterable :id
13
+ <% if suggested_columns.any? -%>
14
+ # filterable <%= suggested_columns.map { |name, _type| ":#{name}" }.join(", ") %>
15
+ <% else -%>
16
+ # filterable :status, :owner_id # columns an agent may filter on
17
+ <% end -%>
18
+
19
+ returns :id
20
+ <% if suggested_columns.any? -%>
21
+ # returns <%= suggested_columns.map { |name, _type| ":#{name}" }.join(", ") %>
22
+ <% else -%>
23
+ # returns :id, :title, :status # columns an agent may read back
24
+ <% end -%>
25
+ <% if suggested_columns.any? -%>
26
+ #
27
+ # Columns and their types:
28
+ <% suggested_columns.each do |name, type| -%>
29
+ # <%= name.ljust(24) %> <%= type %>
30
+ <% end -%>
31
+ <% end -%>
32
+ <% if secret_columns.any? -%>
33
+ #
34
+ # Not suggested, and not to be added: <%= secret_columns.join(", ") %>.
35
+ <% end -%>
36
+
37
+ <% if policy? -%>
38
+ # Every read runs through <%= policy_class_name %>::Scope for the acting
39
+ # user, so an agent sees exactly what that user could see.
40
+ scope_by_policy
41
+ <% else -%>
42
+ # Every read should run through a relation scoped to the acting user. Without
43
+ # a scope the tools read the whole table. Return <%= class_name %>.none for an
44
+ # actor with no access; never widen for a nil actor.
45
+ # scope { |actor| actor ? <%= class_name %>.where(owner: actor) : <%= class_name %>.none }
46
+ <% end -%>
47
+ end
48
+ <% end -%>
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeagent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.2
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-09-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: actionpack
@@ -410,6 +409,7 @@ files:
410
409
  - lib/active_agent.rb
411
410
  - lib/active_agent/base.rb
412
411
  - lib/active_agent/collector.rb
412
+ - lib/active_agent/concerns/authorization.rb
413
413
  - lib/active_agent/concerns/callbacks.rb
414
414
  - lib/active_agent/concerns/delegation.rb
415
415
  - lib/active_agent/concerns/observers.rb
@@ -578,6 +578,9 @@ files:
578
578
  - lib/generators/active_agent/agent/agent_generator.rb
579
579
  - lib/generators/active_agent/install/USAGE
580
580
  - lib/generators/active_agent/install/install_generator.rb
581
+ - lib/generators/active_agent/schema_tools/USAGE
582
+ - lib/generators/active_agent/schema_tools/schema_tools_generator.rb
583
+ - lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt
581
584
  - lib/generators/active_agent/templates/active_agent.yml
582
585
  - lib/generators/active_agent/templates/agent.rb.tt
583
586
  - lib/generators/active_agent/templates/application_agent.rb.tt
@@ -602,7 +605,6 @@ metadata:
602
605
  documentation_uri: https://docs.activeagents.ai
603
606
  source_code_uri: https://github.com/activeagents/activeagent
604
607
  rubygems_mfa_required: 'true'
605
- post_install_message:
606
608
  rdoc_options: []
607
609
  require_paths:
608
610
  - lib
@@ -617,8 +619,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
617
619
  - !ruby/object:Gem::Version
618
620
  version: '0'
619
621
  requirements: []
620
- rubygems_version: 3.5.22
621
- signing_key:
622
+ rubygems_version: 3.6.9
622
623
  specification_version: 4
623
624
  summary: Rails AI Agents Framework
624
625
  test_files: []