activeagent 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/lib/active_agent/base.rb +7 -0
- data/lib/active_agent/concerns/authorization.rb +172 -0
- data/lib/active_agent/concerns/parameterized.rb +40 -3
- data/lib/active_agent/concerns/tooling.rb +3 -1
- data/lib/active_agent/delegation/runner.rb +17 -0
- data/lib/active_agent/evals/diagnosis.rb +62 -6
- data/lib/active_agent/evals/judge.rb +7 -2
- data/lib/active_agent/evals/model_spec.rb +31 -1
- data/lib/active_agent/evals/runner.rb +1 -1
- data/lib/active_agent/evals/scorer.rb +4 -1
- data/lib/active_agent/generation_job.rb +7 -1
- data/lib/active_agent/railtie.rb +10 -0
- data/lib/active_agent/schema_tools.rb +503 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +5 -0
- data/lib/generators/active_agent/schema_tools/USAGE +22 -0
- data/lib/generators/active_agent/schema_tools/schema_tools_generator.rb +84 -0
- data/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt +48 -0
- metadata +7 -2
|
@@ -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(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/active_agent/railtie.rb
CHANGED
|
@@ -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
|
data/lib/active_agent/version.rb
CHANGED
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.
|