activeagent 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7401cdff6a7895238a082383f2ef32683b743946445e11dacbf377470bf31071
4
- data.tar.gz: b0f847d04eedf620c7f5320950d65e5ffc712296ef7db2b7ea6be25f3bc433b7
3
+ metadata.gz: 73e63c87b700b6dec4e3410ae0547ff1c02714fbe79b24175447fb5638a9fe3b
4
+ data.tar.gz: ef361dc3bb7dcab252c0fadf1fb526e689b97b21386a2d50b6ba6eb96a84100a
5
5
  SHA512:
6
- metadata.gz: 0a29cb99c3139f9c264eb6e86677d47ed592fb165c39489be2efc8ea2bb2dd1614bc8802643b812b04319b7356bc37177943bc9c2e0174de276555ba811ba426
7
- data.tar.gz: 6fb392e7cb2f4381d79ae16567edca55d62c585b06f04a6a3da6b31cd0bb7e6149b2e67ceb7e5e143b04bc41123dd1d5bba14f38b33be6e21813e9923308d524
6
+ metadata.gz: 7ed21c4a284e17d0014ef83b09fc56741b96cbb83f219f38dc3e8ad87f511f9ed5b3d902e1b1e9c7e4db11c83bce07ed0effbb2f44d85c35d0da4738598744b3
7
+ data.tar.gz: c3634cfbe8e532889e2ef82b7080b30f0233a0faf1e81b5c37995949a8c7a6d5e6ebf9e3800e39457c54de3803d22b3545d4606d448867a777915cabf1a39b15
data/CHANGELOG.md CHANGED
@@ -7,6 +7,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.2] - 2026-09-11
11
+
12
+ Releases `activeagent` and `actionagent` 1.5.2 from one tag.
13
+
14
+ **1.5.1 was never tagged.** Its version bump reached `main`, but three PRs
15
+ that change `actionagent` merged alongside it, and that release deliberately
16
+ held `actionagent` at 1.5.0 — publishing it would have shipped the fix below
17
+ while leaving every dashboard change of this cycle unpublished, because
18
+ `release.yml` skips a version already on RubyGems. 1.5.2 supersedes it and
19
+ carries both gems. The 1.5.1 notes are kept below as the record of what that
20
+ bump contained.
21
+
22
+ ### Added
23
+
24
+ - **Schema-derived agent tools.** `ActiveAgent::SchemaTools` turns an
25
+ ActiveRecord model plus a declared boundary into a bounded, enumerable tool
26
+ roster — `find_*`, `count_*`, `get_*` — with `filterable` and `returns`
27
+ allowlists. An undeclared column is rejected rather than silently dropped:
28
+ ignoring an unknown filter answers a broader question than was asked while
29
+ still looking like success. Results are capped (25 default, 100 max) with a
30
+ truncation marker the model can see. (#435)
31
+
32
+ - **Host tools reach the dashboard.** `ActionAgent.schema_tools` offers each
33
+ generated tool beside `AgentToolbox`'s built-ins: individually selectable in
34
+ the agent editor, dispatched by name at execution, and nameable in an
35
+ evaluation's `tools:` expectation. Previously a declared schema tool was
36
+ invisible — `definitions_for` returned nothing, the model received no
37
+ schemas and invented tool names in prose while the run scored 0.0 for what
38
+ looked like a model failure. (#435, closes #438)
39
+
40
+ - **Tools are discovered, not declared twice.** Leave `schema_tools` unset and
41
+ every subclass under `schema_tools_path` (`app/agent_tools`) is offered.
42
+ Adding a tool is adding a file. Anonymous classes are excluded from
43
+ discovery: a runtime-built class cannot supersede itself, so it would
44
+ accumulate one per reload. (#435, refs #440)
45
+
46
+ - **`scope_by_policy`** resolves a model's policy by name —
47
+ `Reservation` → `ReservationPolicy::Scope` — instead of hand-writing the
48
+ block. Opt-in, because silently scoping a class that declared none would
49
+ change what an existing tool returns; a missing policy raises at declaration
50
+ rather than quietly reading the whole table. (#435)
51
+
52
+ - **A model's agent starts with that model's tools.** `ReservationAgent` is
53
+ seeded from `ReservationTools` on create. A default, never a restriction:
54
+ any agent may enable any tool, and an explicit selection — including a
55
+ deliberate empty one — is never overwritten. (#435)
56
+
57
+ ### Fixed
58
+
59
+ - **MCP tool-discovery failures are reported instead of running tool-less and
60
+ silent.** `MCPToolDispatcher#tool_definitions` rescued a failed `tools/list`
61
+ to `[]`, so a server that 401s and one that legitimately serves no tools
62
+ were indistinguishable: the agent ran without tools, the model fabricated,
63
+ and the report offered prompt advice for what was a transport failure.
64
+ `discovery_errors` now names the server, its URL and the underlying error,
65
+ and `all_servers_failed?` lets a caller fail loudly rather than grade an
66
+ invented answer. (#434, closes #425)
67
+
68
+ - **Nil VCR filters no longer flake replays**, and the MCP plural is spelled
69
+ correctly. (#436)
70
+
71
+ ## [1.5.1] - 2026-09-11 [UNRELEASED — superseded by 1.5.2]
72
+
73
+ Bumped `activeagent` to 1.5.1 and held `actionagent` at 1.5.0. Never tagged;
74
+ its contents ship in 1.5.2.
75
+
76
+ ### Fixed
77
+
78
+ - **A spec hash names its model rather than reaching the provider as an
79
+ inspected Hash.** `Evals::ModelSpec.parse_all` called `to_s` on each value,
80
+ so a Hash travelled as the model ID and the provider answered
81
+ `{"label" => "openrouter/openai/gpt-4o-mini", ...} is not a valid model ID`
82
+ — every scenario of the run failing before it reached the model. A caller
83
+ passing a plain string was unaffected, which is why a single run worked
84
+ while a whole suite failed. The path is reachable by design rather than by
85
+ misuse: a run persists its models as `specs.map(&:to_h)`, so re-running that
86
+ selection hands the hashes back. `parse_all` now reads a hash's `label`,
87
+ then its `model`, and leaves strings alone.
88
+
10
89
  ## [1.5.0] - 2026-09-10
11
90
 
12
91
  Releases `activeagent` 1.5.0 and `actionagent` 1.5.0 from one tag.
@@ -46,9 +46,23 @@ 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| value.to_s.strip }.reject(&:blank?).uniq.map { |value| parse(value, **options) }
49
+ values.map { |value| coerce(value) }.reject(&:blank?).uniq.map { |value| parse(value, **options) }
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`.
57
+ #
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)
61
+
62
+ value.to_s.strip
63
+ end
64
+ private_class_method :coerce
65
+
52
66
  # The provider a bare model name runs under. A rule whose provider the
53
67
  # caller does not offer is skipped, so an app without Ollama does not
54
68
  # route `name:tag` there.
@@ -114,6 +114,16 @@ module ActiveAgent
114
114
  initializer "active_agent.inflections" do
115
115
  ActiveSupport::Inflector.inflections do |inflect|
116
116
  inflect.acronym "AI"
117
+
118
+ # "MCP" alone does not give the plural: an acronym only matches the
119
+ # whole word, so `mcps` still camelizes to `Mcps`, and a constant
120
+ # spelled `MCPs` underscores back to `mc_ps` — a name that round-trips
121
+ # to something no file is called.
122
+ #
123
+ # Registering the plural as its own acronym makes both directions
124
+ # agree: mcps <-> MCPs, alongside mcp_catalog <-> MCPCatalog.
125
+ inflect.acronym "MCP"
126
+ inflect.acronym "MCPs"
117
127
  end
118
128
  end
119
129
 
@@ -0,0 +1,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_agent/schema_generator"
4
+
5
+ module ActiveAgent
6
+ # Generates a bounded, enumerable set of read-only tools from an
7
+ # ActiveRecord model.
8
+ #
9
+ # An agent given only generic tools (fetch_url, web_search, calculate) has
10
+ # nothing to call when asked a question about the host application's own
11
+ # data, so it answers from the prompt and invents the rest. SchemaTools
12
+ # closes that gap: a host declares which columns of a model an agent may
13
+ # filter on and which it may read back, and gets a fixed roster of
14
+ # function-calling tools it can hand to a provider.
15
+ #
16
+ # @example Declaring a tool set
17
+ # class TicketTools < ActiveAgent::SchemaTools
18
+ # model Ticket
19
+ # filterable :client, :assignee, :status
20
+ # returns :id, :subject, :status, :due_date
21
+ # scope { |actor| TicketPolicy::Scope.new(actor, Ticket).resolve }
22
+ # end
23
+ #
24
+ # TicketTools.tool_definitions.map { |d| d[:name] }
25
+ # # => ["find_tickets", "count_tickets", "get_ticket"]
26
+ #
27
+ # TicketTools.call("find_tickets", actor: current_user, status: "open")
28
+ # # => { results: [{ id: 1, subject: "...", ... }], count: 1, truncated: false }
29
+ #
30
+ # = Design properties
31
+ #
32
+ # * **Allowlist-gated.** Only columns passed to {.filterable} may appear in a
33
+ # filter and only columns passed to {.returns} are ever read back. An
34
+ # undeclared column is *rejected*, not dropped — silently ignoring an
35
+ # unknown filter would answer a narrower question than the model asked
36
+ # while looking like a success, which is how a model ends up confidently
37
+ # reporting the unfiltered set. Rejecting is also what keeps the boundary
38
+ # real: without it a model could filter on +users.password_digest+ one
39
+ # character at a time and read a secret out of the row counts.
40
+ #
41
+ # * **Fixed roster, generated at boot.** Tools are built with +define_method+
42
+ # at declaration time rather than resolved through +method_missing+,
43
+ # because every consumer needs to *enumerate* the roster before any call
44
+ # happens: the dashboard lists available tools, MCP +tools/list+ must
45
+ # answer without being told a name first, and evals assert on an expected
46
+ # +tools:+ set. A +method_missing+ design can answer "do you respond to
47
+ # this?" but cannot answer "what is there?".
48
+ #
49
+ # * **Bounded results.** A +find_*+ with no filters would otherwise select
50
+ # the whole table into a prompt. Every query is capped and says so via a
51
+ # +truncated+ flag, so the model can tell "these are all of them" from
52
+ # "these are the first #{DEFAULT_LIMIT}".
53
+ #
54
+ # * **Authorization is the host's seam, not ours.** {.scope} takes a block
55
+ # receiving the caller's actor and returning a relation; the generated
56
+ # tools query through it. SchemaTools does not know what an actor is and
57
+ # deliberately does not try to authorize — hosts have Pundit, CanCan, or
58
+ # nothing, and a framework guess would be either wrong or in the way.
59
+ # Omitting +scope+ runs unscoped, which is a legitimate choice for a
60
+ # single-tenant or already-trusted context.
61
+ class SchemaTools
62
+ # Default number of rows a find_* tool returns when the caller does not
63
+ # ask for a specific limit.
64
+ DEFAULT_LIMIT = 25
65
+
66
+ # Ceiling on rows a single find_* call may return, regardless of what the
67
+ # model passes as +limit+. A model that wants "all of them" will happily
68
+ # ask for 10_000; this is what stops that from becoming the prompt.
69
+ MAX_LIMIT = 100
70
+
71
+ # Raised when a tool call names a column outside the declared allowlists,
72
+ # or is otherwise outside the declared boundary.
73
+ class UnpermittedAttribute < ArgumentError; end
74
+
75
+ # Raised when a class declares tools without first declaring a model.
76
+ class MissingModel < StandardError; end
77
+
78
+ class << self
79
+ # Declares (or reads) the ActiveRecord class these tools expose.
80
+ #
81
+ # Calling this with a model is what triggers tool generation, so it must
82
+ # come before {.filterable} / {.returns} in the class body.
83
+ #
84
+ # @param klass [Class, nil] an ActiveRecord class, or nil to read
85
+ # @return [Class] the declared model
86
+ def model(klass = nil)
87
+ return @model if klass.nil?
88
+
89
+ unless defined?(ActiveRecord::Base) && klass < ActiveRecord::Base
90
+ raise ArgumentError, "#{klass} is not an ActiveRecord class"
91
+ end
92
+
93
+ @model = klass
94
+ define_tools!
95
+ @model
96
+ end
97
+
98
+ # Declares the only columns that may be used as filters.
99
+ #
100
+ # Association names are accepted and resolved to their foreign key, so a
101
+ # host can write +filterable :client+ rather than leaking +client_id+
102
+ # into the tool signature the model sees.
103
+ #
104
+ # @param names [Array<Symbol, String>] column or belongs_to association names
105
+ # @return [Array<Symbol>] the resolved filterable column names
106
+ def filterable(*names)
107
+ return @filterable || [] if names.empty?
108
+
109
+ @filterable = names.flatten.map { |name| resolve_column!(name) }
110
+ define_tools!
111
+ @filterable
112
+ end
113
+
114
+ # Declares the only columns that may be read back.
115
+ #
116
+ # @param names [Array<Symbol, String>] column names
117
+ # @return [Array<Symbol>] the declared return columns
118
+ def returns(*names)
119
+ return @returns || [] if names.empty?
120
+
121
+ @returns = names.flatten.map { |name| resolve_column!(name) }
122
+ define_tools!
123
+ @returns
124
+ end
125
+
126
+ # Registers the host's authorization seam.
127
+ #
128
+ # The block receives the actor passed to {.call} and must return an
129
+ # ActiveRecord relation. It is called on every tool invocation rather
130
+ # than memoized, because the relation depends on the actor and a cached
131
+ # one would serve the first caller's rows to the second.
132
+ #
133
+ # @yieldparam actor [Object] whatever the host passes as +actor:+
134
+ # @yieldreturn [ActiveRecord::Relation]
135
+ # @return [Proc, nil]
136
+ # Scopes reads through the host's policy for this model, found by name:
137
+ # Reservation -> ReservationPolicy::Scope, called as
138
+ # `Scope.new(actor, model).resolve`.
139
+ #
140
+ # class ReservationTools < ActiveAgent::SchemaTools
141
+ # model Reservation
142
+ # scope_by_policy
143
+ # end
144
+ #
145
+ # Opt-in rather than automatic: silently scoping a class that declared no
146
+ # scope would change what an existing tool returns, and a host may run
147
+ # its authorization somewhere other than a Pundit-shaped policy.
148
+ #
149
+ # Raises if the policy cannot be found, so a typo or a missing policy
150
+ # fails at declaration rather than quietly reading the whole table.
151
+ def scope_by_policy(policy = nil, method: :resolve)
152
+ raise MissingModel, "Declare `model` before `scope_by_policy`." unless @model
153
+
154
+ resolved = policy || "#{@model.name}Policy::Scope".safe_constantize
155
+ if resolved.nil?
156
+ raise ArgumentError,
157
+ "No policy found for #{@model.name}. Expected #{@model.name}Policy::Scope, " \
158
+ "or pass one: `scope_by_policy MyScope`."
159
+ end
160
+
161
+ model_class = @model
162
+ scope { |actor| resolved.new(actor, model_class).public_send(method) }
163
+ end
164
+
165
+ def scope(&block)
166
+ return @scope unless block
167
+
168
+ @scope = block
169
+ end
170
+
171
+ # The full, fixed tool roster in provider function-calling format.
172
+ #
173
+ # @return [Array<Hash>] tool definitions with :name, :description, :parameters
174
+ def tool_definitions
175
+ (@tool_definitions || {}).values.map(&:deep_dup)
176
+ end
177
+
178
+ # @return [Array<String>] the names of every generated tool
179
+ def tool_names
180
+ (@tool_definitions || {}).keys
181
+ end
182
+
183
+ # @param name [String, Symbol]
184
+ # @return [Boolean] whether this class generated a tool by that name
185
+ def tool?(name)
186
+ (@tool_definitions || {}).key?(name.to_s)
187
+ end
188
+
189
+ # Invokes a generated tool by name.
190
+ #
191
+ # Mirrors the dashboard toolbox contract: boundary violations come back
192
+ # as +{ error: ... }+ rather than raising, so a model that guesses a
193
+ # column name gets a correction it can act on instead of killing the
194
+ # run. Genuine programming errors are left to raise.
195
+ #
196
+ # @param name [String, Symbol] the tool name
197
+ # @param actor [Object, nil] passed through to the {.scope} block
198
+ # @param arguments [Hash] tool arguments
199
+ # @return [Hash] the tool result, or +{ error: String }+
200
+ def call(name, actor: nil, **arguments)
201
+ return { error: "Unknown tool: #{name}" } unless tool?(name)
202
+
203
+ public_send(name, actor: actor, **arguments)
204
+ rescue UnpermittedAttribute, MissingModel => e
205
+ { error: e.message }
206
+ rescue ArgumentError => e
207
+ { error: "Invalid arguments for #{name}: #{e.message}" }
208
+ end
209
+
210
+ # Subclasses get their own declarations rather than sharing the
211
+ # parent's — a roster inherited by reference would let one tool class's
212
+ # allowlist silently widen another's.
213
+ def inherited(subclass)
214
+ super
215
+ subclass.instance_variable_set(:@model, @model)
216
+ subclass.instance_variable_set(:@filterable, (@filterable || []).dup)
217
+ subclass.instance_variable_set(:@returns, (@returns || []).dup)
218
+ subclass.instance_variable_set(:@scope, @scope)
219
+ subclass.instance_variable_set(:@tool_definitions, (@tool_definitions || {}).deep_dup)
220
+ end
221
+
222
+ # Resolves the relation a tool queries through.
223
+ #
224
+ # @api private
225
+ def relation_for(actor)
226
+ raise MissingModel, "No model declared. Call `model MyModel` first." unless @model
227
+
228
+ return @model.all unless @scope
229
+
230
+ relation = @scope.arity.zero? ? @scope.call : @scope.call(actor)
231
+ raise ArgumentError, "scope block must return an ActiveRecord::Relation" unless relation.respond_to?(:where)
232
+
233
+ relation
234
+ end
235
+
236
+ # Validates and normalizes a filter hash against the allowlist.
237
+ #
238
+ # @api private
239
+ # @raise [UnpermittedAttribute] if any key is not declared filterable
240
+ def permitted_filters!(arguments)
241
+ filters = arguments.each_with_object({}) do |(key, value), memo|
242
+ next if value.nil?
243
+
244
+ column = key.to_sym
245
+ unless filterable.include?(column)
246
+ raise UnpermittedAttribute,
247
+ "`#{key}` is not a filterable attribute. Allowed filters: #{filterable.join(", ")}"
248
+ end
249
+
250
+ memo[column] = value
251
+ end
252
+
253
+ filters
254
+ end
255
+
256
+ # Projects a record down to the declared return columns.
257
+ #
258
+ # The projection happens in SQL (+select+) as well as here, but the Ruby
259
+ # side is what actually guarantees the boundary: a +scope+ block that
260
+ # ends in +includes+ or a raw +select+ can hand back a record carrying
261
+ # more columns than were asked for.
262
+ #
263
+ # @api private
264
+ def project(record)
265
+ returns.index_with { |column| serialize_value(record.read_attribute(column)) }
266
+ end
267
+
268
+ private
269
+
270
+ # Dates and times reach the model as text, not as Ruby objects, so
271
+ # normalize them once here rather than letting each provider's JSON
272
+ # encoder pick its own format.
273
+ def serialize_value(value)
274
+ case value
275
+ when Time, DateTime, ActiveSupport::TimeWithZone then value.iso8601
276
+ when Date then value.to_s
277
+ else value
278
+ end
279
+ end
280
+
281
+ # Maps a declared name onto a real column, accepting belongs_to
282
+ # association names as a convenience for their foreign key.
283
+ def resolve_column!(name)
284
+ raise MissingModel, "Declare `model MyModel` before columns" unless @model
285
+
286
+ column = name.to_sym
287
+ return column if @model.column_names.include?(column.to_s)
288
+
289
+ reflection = @model.reflect_on_association(column)
290
+ if reflection&.belongs_to? && @model.column_names.include?(reflection.foreign_key.to_s)
291
+ return reflection.foreign_key.to_sym
292
+ end
293
+
294
+ raise UnpermittedAttribute, "`#{name}` is not a column on #{@model.name}"
295
+ end
296
+
297
+ # Builds the fixed tool roster.
298
+ #
299
+ # Re-run after each declaration so the definitions always reflect the
300
+ # current allowlists; the class body calls this two or three times
301
+ # during load and then never again.
302
+ def define_tools!
303
+ return unless @model
304
+
305
+ @tool_definitions = {}
306
+
307
+ define_find_tool
308
+ define_count_tool
309
+ define_get_tool
310
+ end
311
+
312
+ # Parameter schemas come from SchemaGenerator rather than a local type
313
+ # map, so a column's type, format, and enum (from an inclusion
314
+ # validator) are described the same way they are everywhere else in the
315
+ # framework.
316
+ def filter_properties
317
+ return {} if filterable.empty?
318
+
319
+ schema = ActiveAgent::SchemaGenerator::Builder.json_schema_from_model(
320
+ @model, include_id: true
321
+ )
322
+ properties = schema[:schema][:properties]
323
+
324
+ filterable.index_with { |column| (properties[column] || { type: "string" }).deep_dup }
325
+ end
326
+
327
+ def resource_name
328
+ @model.name.underscore
329
+ end
330
+
331
+ def collection_name
332
+ resource_name.pluralize
333
+ end
334
+
335
+ def define_find_tool
336
+ name = "find_#{collection_name}"
337
+ filters = filter_properties
338
+
339
+ register_tool(
340
+ name,
341
+ description: "Find #{collection_name.humanize.downcase} matching the given filters. " \
342
+ "Returns at most #{MAX_LIMIT} records with these fields: #{returns.join(", ")}.",
343
+ properties: filters.merge(
344
+ limit: {
345
+ type: "integer",
346
+ description: "Maximum records to return (default #{DEFAULT_LIMIT}, max #{MAX_LIMIT})"
347
+ }
348
+ ),
349
+ required: []
350
+ )
351
+
352
+ define_singleton_method(name) do |actor: nil, limit: nil, **arguments|
353
+ filters = permitted_filters!(arguments)
354
+ capped = normalize_limit(limit)
355
+
356
+ relation = relation_for(actor).where(filters)
357
+ # One extra row distinguishes "exactly at the limit" from "more than
358
+ # the limit", without a second COUNT query.
359
+ records = relation.limit(capped + 1).to_a
360
+ truncated = records.size > capped
361
+
362
+ {
363
+ results: records.first(capped).map { |record| project(record) },
364
+ count: [ records.size, capped ].min,
365
+ truncated: truncated
366
+ }
367
+ end
368
+ end
369
+
370
+ def define_count_tool
371
+ name = "count_#{collection_name}"
372
+
373
+ register_tool(
374
+ name,
375
+ description: "Count #{collection_name.humanize.downcase} matching the given filters.",
376
+ properties: filter_properties,
377
+ required: []
378
+ )
379
+
380
+ define_singleton_method(name) do |actor: nil, **arguments|
381
+ filters = permitted_filters!(arguments)
382
+
383
+ { count: relation_for(actor).where(filters).count }
384
+ end
385
+ end
386
+
387
+ def define_get_tool
388
+ name = "get_#{resource_name}"
389
+
390
+ register_tool(
391
+ name,
392
+ description: "Fetch a single #{resource_name.humanize.downcase} by id. " \
393
+ "Returns these fields: #{returns.join(", ")}.",
394
+ properties: {
395
+ id: { type: "integer", description: "The record id" }
396
+ },
397
+ required: [ "id" ]
398
+ )
399
+
400
+ define_singleton_method(name) do |actor: nil, id: nil|
401
+ return { error: "id is required" } if id.nil?
402
+
403
+ # find_by through the scoped relation, not find: a record the actor
404
+ # cannot see must read as "not found", never as a 404-vs-403 signal
405
+ # the model could use to probe for existence.
406
+ record = relation_for(actor).find_by(id: id)
407
+ return { error: "No #{resource_name} found with id #{id}" } unless record
408
+
409
+ project(record)
410
+ end
411
+ end
412
+
413
+ def register_tool(name, description:, properties:, required:)
414
+ @tool_definitions[name] = {
415
+ name: name,
416
+ description: description,
417
+ parameters: {
418
+ type: "object",
419
+ properties: properties,
420
+ required: required
421
+ }
422
+ }
423
+ end
424
+
425
+ # Clamp rather than reject an oversized limit: a model asking for 1000
426
+ # rows wants as many as it can get, and an error would just make it ask
427
+ # again. The truncated flag tells it what actually happened.
428
+ def normalize_limit(limit)
429
+ return DEFAULT_LIMIT if limit.nil?
430
+
431
+ value = Integer(limit)
432
+ return DEFAULT_LIMIT if value <= 0
433
+
434
+ [ value, MAX_LIMIT ].min
435
+ end
436
+ end
437
+ end
438
+ end
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.5.0"
2
+ VERSION = "1.5.2"
3
3
  end
data/lib/active_agent.rb CHANGED
@@ -103,6 +103,8 @@ module ActiveAgent
103
103
  autoload :Previews, "active_agent/concerns/preview"
104
104
  autoload :GenerationJob
105
105
  autoload :ModelCapabilities
106
+ autoload :SchemaGenerator
107
+ autoload :SchemaTools
106
108
  autoload :Observers, "active_agent/concerns/observers"
107
109
  autoload :Provider, "active_agent/concerns/provider"
108
110
  autoload :Rescue, "active_agent/concerns/rescue"
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeagent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Bowen
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-11 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: actionpack
@@ -562,6 +563,7 @@ files:
562
563
  - lib/active_agent/railtie.rb
563
564
  - lib/active_agent/railtie/schema_generator_extension.rb
564
565
  - lib/active_agent/schema_generator.rb
566
+ - lib/active_agent/schema_tools.rb
565
567
  - lib/active_agent/service.rb
566
568
  - lib/active_agent/telemetry.rb
567
569
  - lib/active_agent/telemetry/configuration.rb
@@ -600,6 +602,7 @@ metadata:
600
602
  documentation_uri: https://docs.activeagents.ai
601
603
  source_code_uri: https://github.com/activeagents/activeagent
602
604
  rubygems_mfa_required: 'true'
605
+ post_install_message:
603
606
  rdoc_options: []
604
607
  require_paths:
605
608
  - lib
@@ -614,7 +617,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
614
617
  - !ruby/object:Gem::Version
615
618
  version: '0'
616
619
  requirements: []
617
- rubygems_version: 4.0.16
620
+ rubygems_version: 3.5.22
621
+ signing_key:
618
622
  specification_version: 4
619
623
  summary: Rails AI Agents Framework
620
624
  test_files: []