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.
@@ -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,9 +46,39 @@ 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.filter_map { |value| from_value(value, **options) }.uniq(&:label)
50
50
  end
51
51
 
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.
61
+ #
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
70
+
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)
79
+ end
80
+ private_class_method :from_value
81
+
52
82
  # The provider a bare model name runs under. A rule whose provider the
53
83
  # caller does not offer is skipped, so an app without Ollama does not
54
84
  # route `name:tag` there.
@@ -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
@@ -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,503 @@
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
+ # 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
+
236
+ # The full, fixed tool roster in provider function-calling format.
237
+ #
238
+ # @return [Array<Hash>] tool definitions with :name, :description, :parameters
239
+ def tool_definitions
240
+ (@tool_definitions || {}).values.map(&:deep_dup)
241
+ end
242
+
243
+ # @return [Array<String>] the names of every generated tool
244
+ def tool_names
245
+ (@tool_definitions || {}).keys
246
+ end
247
+
248
+ # @param name [String, Symbol]
249
+ # @return [Boolean] whether this class generated a tool by that name
250
+ def tool?(name)
251
+ (@tool_definitions || {}).key?(name.to_s)
252
+ end
253
+
254
+ # Invokes a generated tool by name.
255
+ #
256
+ # Mirrors the dashboard toolbox contract: boundary violations come back
257
+ # as +{ error: ... }+ rather than raising, so a model that guesses a
258
+ # column name gets a correction it can act on instead of killing the
259
+ # run. Genuine programming errors are left to raise.
260
+ #
261
+ # @param name [String, Symbol] the tool name
262
+ # @param actor [Object, nil] passed through to the {.scope} block
263
+ # @param arguments [Hash] tool arguments
264
+ # @return [Hash] the tool result, or +{ error: String }+
265
+ def call(name, actor: nil, **arguments)
266
+ return { error: "Unknown tool: #{name}" } unless tool?(name)
267
+
268
+ public_send(name, actor: actor, **arguments)
269
+ rescue UnpermittedAttribute, MissingModel => e
270
+ { error: e.message }
271
+ rescue ArgumentError => e
272
+ { error: "Invalid arguments for #{name}: #{e.message}" }
273
+ end
274
+
275
+ # Subclasses get their own declarations rather than sharing the
276
+ # parent's — a roster inherited by reference would let one tool class's
277
+ # allowlist silently widen another's.
278
+ def inherited(subclass)
279
+ super
280
+ subclass.instance_variable_set(:@model, @model)
281
+ subclass.instance_variable_set(:@filterable, (@filterable || []).dup)
282
+ subclass.instance_variable_set(:@returns, (@returns || []).dup)
283
+ subclass.instance_variable_set(:@scope, @scope)
284
+ subclass.instance_variable_set(:@tool_definitions, (@tool_definitions || {}).deep_dup)
285
+ end
286
+
287
+ # Resolves the relation a tool queries through.
288
+ #
289
+ # @api private
290
+ def relation_for(actor)
291
+ raise MissingModel, "No model declared. Call `model MyModel` first." unless @model
292
+
293
+ return @model.all unless @scope
294
+
295
+ relation = @scope.arity.zero? ? @scope.call : @scope.call(actor)
296
+ raise ArgumentError, "scope block must return an ActiveRecord::Relation" unless relation.respond_to?(:where)
297
+
298
+ relation
299
+ end
300
+
301
+ # Validates and normalizes a filter hash against the allowlist.
302
+ #
303
+ # @api private
304
+ # @raise [UnpermittedAttribute] if any key is not declared filterable
305
+ def permitted_filters!(arguments)
306
+ filters = arguments.each_with_object({}) do |(key, value), memo|
307
+ next if value.nil?
308
+
309
+ column = key.to_sym
310
+ unless filterable.include?(column)
311
+ raise UnpermittedAttribute,
312
+ "`#{key}` is not a filterable attribute. Allowed filters: #{filterable.join(", ")}"
313
+ end
314
+
315
+ memo[column] = value
316
+ end
317
+
318
+ filters
319
+ end
320
+
321
+ # Projects a record down to the declared return columns.
322
+ #
323
+ # The projection happens in SQL (+select+) as well as here, but the Ruby
324
+ # side is what actually guarantees the boundary: a +scope+ block that
325
+ # ends in +includes+ or a raw +select+ can hand back a record carrying
326
+ # more columns than were asked for.
327
+ #
328
+ # @api private
329
+ def project(record)
330
+ returns.index_with { |column| serialize_value(record.read_attribute(column)) }
331
+ end
332
+
333
+ private
334
+
335
+ # Dates and times reach the model as text, not as Ruby objects, so
336
+ # normalize them once here rather than letting each provider's JSON
337
+ # encoder pick its own format.
338
+ def serialize_value(value)
339
+ case value
340
+ when Time, DateTime, ActiveSupport::TimeWithZone then value.iso8601
341
+ when Date then value.to_s
342
+ else value
343
+ end
344
+ end
345
+
346
+ # Maps a declared name onto a real column, accepting belongs_to
347
+ # association names as a convenience for their foreign key.
348
+ def resolve_column!(name)
349
+ raise MissingModel, "Declare `model MyModel` before columns" unless @model
350
+
351
+ column = name.to_sym
352
+ return column if @model.column_names.include?(column.to_s)
353
+
354
+ reflection = @model.reflect_on_association(column)
355
+ if reflection&.belongs_to? && @model.column_names.include?(reflection.foreign_key.to_s)
356
+ return reflection.foreign_key.to_sym
357
+ end
358
+
359
+ raise UnpermittedAttribute, "`#{name}` is not a column on #{@model.name}"
360
+ end
361
+
362
+ # Builds the fixed tool roster.
363
+ #
364
+ # Re-run after each declaration so the definitions always reflect the
365
+ # current allowlists; the class body calls this two or three times
366
+ # during load and then never again.
367
+ def define_tools!
368
+ return unless @model
369
+
370
+ @tool_definitions = {}
371
+
372
+ define_find_tool
373
+ define_count_tool
374
+ define_get_tool
375
+ end
376
+
377
+ # Parameter schemas come from SchemaGenerator rather than a local type
378
+ # map, so a column's type, format, and enum (from an inclusion
379
+ # validator) are described the same way they are everywhere else in the
380
+ # framework.
381
+ def filter_properties
382
+ return {} if filterable.empty?
383
+
384
+ schema = ActiveAgent::SchemaGenerator::Builder.json_schema_from_model(
385
+ @model, include_id: true
386
+ )
387
+ properties = schema[:schema][:properties]
388
+
389
+ filterable.index_with { |column| (properties[column] || { type: "string" }).deep_dup }
390
+ end
391
+
392
+ def resource_name
393
+ @model.name.underscore
394
+ end
395
+
396
+ def collection_name
397
+ resource_name.pluralize
398
+ end
399
+
400
+ def define_find_tool
401
+ name = "find_#{collection_name}"
402
+ filters = filter_properties
403
+
404
+ register_tool(
405
+ name,
406
+ description: "Find #{collection_name.humanize.downcase} matching the given filters. " \
407
+ "Returns at most #{MAX_LIMIT} records with these fields: #{returns.join(", ")}.",
408
+ properties: filters.merge(
409
+ limit: {
410
+ type: "integer",
411
+ description: "Maximum records to return (default #{DEFAULT_LIMIT}, max #{MAX_LIMIT})"
412
+ }
413
+ ),
414
+ required: []
415
+ )
416
+
417
+ define_singleton_method(name) do |actor: nil, limit: nil, **arguments|
418
+ filters = permitted_filters!(arguments)
419
+ capped = normalize_limit(limit)
420
+
421
+ relation = relation_for(actor).where(filters)
422
+ # One extra row distinguishes "exactly at the limit" from "more than
423
+ # the limit", without a second COUNT query.
424
+ records = relation.limit(capped + 1).to_a
425
+ truncated = records.size > capped
426
+
427
+ {
428
+ results: records.first(capped).map { |record| project(record) },
429
+ count: [ records.size, capped ].min,
430
+ truncated: truncated
431
+ }
432
+ end
433
+ end
434
+
435
+ def define_count_tool
436
+ name = "count_#{collection_name}"
437
+
438
+ register_tool(
439
+ name,
440
+ description: "Count #{collection_name.humanize.downcase} matching the given filters.",
441
+ properties: filter_properties,
442
+ required: []
443
+ )
444
+
445
+ define_singleton_method(name) do |actor: nil, **arguments|
446
+ filters = permitted_filters!(arguments)
447
+
448
+ { count: relation_for(actor).where(filters).count }
449
+ end
450
+ end
451
+
452
+ def define_get_tool
453
+ name = "get_#{resource_name}"
454
+
455
+ register_tool(
456
+ name,
457
+ description: "Fetch a single #{resource_name.humanize.downcase} by id. " \
458
+ "Returns these fields: #{returns.join(", ")}.",
459
+ properties: {
460
+ id: { type: "integer", description: "The record id" }
461
+ },
462
+ required: [ "id" ]
463
+ )
464
+
465
+ define_singleton_method(name) do |actor: nil, id: nil|
466
+ return { error: "id is required" } if id.nil?
467
+
468
+ # find_by through the scoped relation, not find: a record the actor
469
+ # cannot see must read as "not found", never as a 404-vs-403 signal
470
+ # the model could use to probe for existence.
471
+ record = relation_for(actor).find_by(id: id)
472
+ return { error: "No #{resource_name} found with id #{id}" } unless record
473
+
474
+ project(record)
475
+ end
476
+ end
477
+
478
+ def register_tool(name, description:, properties:, required:)
479
+ @tool_definitions[name] = {
480
+ name: name,
481
+ description: description,
482
+ parameters: {
483
+ type: "object",
484
+ properties: properties,
485
+ required: required
486
+ }
487
+ }
488
+ end
489
+
490
+ # Clamp rather than reject an oversized limit: a model asking for 1000
491
+ # rows wants as many as it can get, and an error would just make it ask
492
+ # again. The truncated flag tells it what actually happened.
493
+ def normalize_limit(limit)
494
+ return DEFAULT_LIMIT if limit.nil?
495
+
496
+ value = Integer(limit)
497
+ return DEFAULT_LIMIT if value <= 0
498
+
499
+ [ value, MAX_LIMIT ].min
500
+ end
501
+ end
502
+ end
503
+ end
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.5.0"
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"
@@ -103,6 +106,8 @@ module ActiveAgent
103
106
  autoload :Previews, "active_agent/concerns/preview"
104
107
  autoload :GenerationJob
105
108
  autoload :ModelCapabilities
109
+ autoload :SchemaGenerator
110
+ autoload :SchemaTools
106
111
  autoload :Observers, "active_agent/concerns/observers"
107
112
  autoload :Provider, "active_agent/concerns/provider"
108
113
  autoload :Rescue, "active_agent/concerns/rescue"
@@ -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.