From 7d4598c37b5f85ea2ae9ea0b472cd1d741c2368a Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Sat, 12 Sep 2026 12:06:52 -0700 Subject: [PATCH 1/2] feat(generators): a schema_tools generator that lists every column, commented out rails generate active_agent:schema_tools Reservation writes a starter ActiveAgent::SchemaTools class under app/agent_tools. It exposes nothing beyond id until a column is moved into filterable or returns; every column the model has is listed, commented out, with its type, so the allowlist is a review step rather than a blank page, and columns that look like secrets are left off the list. Reads go through Policy::Scope when it exists (--policy / --no-policy decide explicitly). This is #440's second option: the roster is still declared, once, but the declaration is no longer written from scratch. Also the first documentation of SchemaTools itself, in docs/actions/tools.md. Refs #440. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XMSRnSxYS9mRx1hSjytB9Z --- CHANGELOG.md | 12 +++ docs/actions/tools.md | 38 ++++++++ .../active_agent/schema_tools/USAGE | 22 +++++ .../schema_tools/schema_tools_generator.rb | 84 +++++++++++++++++ .../schema_tools/templates/schema_tools.rb.tt | 48 ++++++++++ .../schema_tools_generator_test.rb | 93 +++++++++++++++++++ 6 files changed, 297 insertions(+) create mode 100644 lib/generators/active_agent/schema_tools/USAGE create mode 100644 lib/generators/active_agent/schema_tools/schema_tools_generator.rb create mode 100644 lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt create mode 100644 test/generators/active_agent/schema_tools_generator_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 850a0f74..1feb2cac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`rails generate active_agent:schema_tools Reservation`** writes a starter + `ActiveAgent::SchemaTools` class under `app/agent_tools`. It exposes nothing + beyond `id` until a column is moved into `filterable` or `returns`; every + column the model has is listed, commented out, with its type, so the + allowlist is a review step rather than a blank page, and columns that look + like secrets are left off the list. Reads are scoped through + `Policy::Scope` when it exists (`--policy` / `--no-policy` decide + explicitly). This is #440's second option: the roster is still declared, + once, but the declaration is no longer written from scratch. (#440) + ## [1.5.2] - 2026-09-11 Releases `activeagent` and `actionagent` 1.5.2 from one tag. diff --git a/docs/actions/tools.md b/docs/actions/tools.md index 109f61f4..38938bba 100644 --- a/docs/actions/tools.md +++ b/docs/actions/tools.md @@ -144,6 +144,44 @@ If the LLM doesn't call your function when expected, improve the tool descriptio If the LLM passes unexpected parameters, add detailed parameter descriptions with `enum` for restricted choices and mark required parameters explicitly. +## Bounded Reads Over a Model (Schema Tools) + +An agent given only generic tools has nothing to call when asked about the application's own records, so it answers from the prompt and invents the rest. `ActiveAgent::SchemaTools` closes that gap with a fixed, enumerable roster generated from one model — `find_`, `count_`, `get_` — over the columns you declare and through the scope you name: + +```ruby +# app/agent_tools/reservation_tools.rb +class ReservationTools < ActiveAgent::SchemaTools + model Reservation + + filterable :status, :guest_id, :arrives_on # the only columns a filter may name + returns :id, :status, :guest_id, :arrives_on # the only columns ever read back + + scope_by_policy # ReservationPolicy::Scope.new(actor, Reservation).resolve +end + +ReservationTools.tool_names +# => ["find_reservations", "count_reservations", "get_reservation"] + +ReservationTools.call("find_reservations", actor: current_user, status: "held") +# => { results: [...], count: 3, truncated: false } +``` + +Three properties make this safe to hand to a model: + +- **Allowlists reject, never drop.** A filter on an undeclared column comes back as `{ error: ... }` the model can act on, rather than silently answering a broader question — and rather than letting a model read `password_digest` one character at a time through row counts. +- **Results are capped** (25 by default, 100 at most) with a `truncated` flag, so a `find_*` with no filters cannot select a table into the prompt. +- **The scope is yours.** `scope { |actor| ... }` receives whatever your host passes as `actor:` and returns the relation to read through; `scope_by_policy` resolves `Policy::Scope` by name. Omit both and the tools read unscoped — a deliberate choice, not a default. + +The tool definitions are ordinary [common-format tools](#common-tools-format-recommended), so an agent offers them with `prompt(tools: ReservationTools.tool_definitions)` and answers each call with `ReservationTools.call(name, actor: current_user, **arguments)`. The [dashboard engine](/framework/dashboard) discovers every class under `app/agent_tools` and offers each generated tool in the agent editor. + +Start from the generator rather than a blank file: + +```bash +bin/rails generate active_agent:schema_tools Reservation +``` + +It writes the class above with **every column listed, commented out, with its type**, exposing nothing beyond `id` until you move a column into `filterable` or `returns`. Which columns an agent may see is a judgement about exposure, not a fact about the table, so that decision stays a review step; columns that look like secrets are left off the list altogether. + ## Delegating to Another Agent When the work behind a tool is itself an AI task — summarizing, classifying, translating — reach for [delegation](/actions/delegation) instead of a plain function. A delegated sub-agent keeps its own instructions, templates and model, and runs under a declared schema, a cost/latency budget, and a swappable backend: diff --git a/lib/generators/active_agent/schema_tools/USAGE b/lib/generators/active_agent/schema_tools/USAGE new file mode 100644 index 00000000..3929cbe6 --- /dev/null +++ b/lib/generators/active_agent/schema_tools/USAGE @@ -0,0 +1,22 @@ +Description: + Writes a starter ActiveAgent::SchemaTools class for a model under + app/agent_tools. The class generates find_, count_ and + get_ tools, and exposes nothing beyond `id` until you move a + column into `filterable` or `returns` — every column the model has is + listed, commented out, so the allowlist is a review step rather than a + blank page. Columns that look like secrets are left out of the list. + + When Policy::Scope exists the class scopes every read through it + (`scope_by_policy`); otherwise a `scope` block is suggested. Pass + --policy or --no-policy to decide explicitly. + +Examples: + `bin/rails generate active_agent:schema_tools Reservation` + + creates: + app/agent_tools/reservation_tools.rb + + `bin/rails generate active_agent:schema_tools Reservation --policy` + + scopes reads through ReservationPolicy::Scope even if it cannot be + found from here. diff --git a/lib/generators/active_agent/schema_tools/schema_tools_generator.rb b/lib/generators/active_agent/schema_tools/schema_tools_generator.rb new file mode 100644 index 00000000..5bfef555 --- /dev/null +++ b/lib/generators/active_agent/schema_tools/schema_tools_generator.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module ActiveAgent + module Generators + # Writes a starter ActiveAgent::SchemaTools class for one model: + # + # bin/rails generate active_agent:schema_tools Reservation + # + # The class it writes exposes nothing beyond +id+ until someone uncomments + # a column, because which columns an agent may filter on and read back is + # a judgement about exposure, not a fact about the table (#440). Every + # column the model has is listed, commented out, minus the ones that look + # like secrets, so the allowlist is a review step rather than a blank page. + class SchemaToolsGenerator < ::Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + # Columns never suggested, whatever the model: reading one back would + # hand a model a credential, and filtering on one leaks it a character + # at a time through the row counts. + SECRET_COLUMNS = /password|digest|token|secret|api_key|otp|encrypted|ssn/i + + class_option :policy, type: :boolean, default: nil, + desc: "Scope every read through Policy::Scope (default: when that policy exists)" + + check_class_collision suffix: "Tools" + + def create_tools_file + template "schema_tools.rb.tt", File.join("app/agent_tools", class_path, "#{file_name}_tools.rb") + end + + private + + # "Reservation" and "ReservationTools" both name the Reservation model. + def file_name # :doc: + @_file_name ||= super.sub(/_tools\z/i, "") + end + + def model_class + @model_class ||= class_name.safe_constantize + end + + def policy_class_name + "#{class_name}Policy" + end + + def policy? + return options[:policy] unless options[:policy].nil? + + "#{policy_class_name}::Scope".safe_constantize.present? + end + + # [name, type] for every column the model has, or [] when the model or + # its table cannot be read from here (a model that does not exist yet, + # or a database that is not set up) — the file is still written. + def columns + @columns ||= begin + if model_class.respond_to?(:columns) && model_class.respond_to?(:table_exists?) && model_class.table_exists? + model_class.columns.map { |column| [ column.name, column.type ] } + else + [] + end + rescue StandardError + [] + end + end + + def suggested_columns + columns.reject { |name, _type| name == "id" || name.match?(SECRET_COLUMNS) } + end + + def secret_columns + columns.select { |name, _type| name.match?(SECRET_COLUMNS) }.map(&:first) + end + + def collection_name + class_name.demodulize.underscore.pluralize + end + + def resource_name + class_name.demodulize.underscore + end + end + end +end diff --git a/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt b/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt new file mode 100644 index 00000000..04487398 --- /dev/null +++ b/lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt @@ -0,0 +1,48 @@ +<% module_namespacing do -%> +# Bounded, read-only agent tools over <%= class_name %>: +# find_<%= collection_name %>, count_<%= collection_name %>, get_<%= resource_name %>. +# +# Nothing is exposed until you say so. Move a column from the commented lists +# into `filterable` (an agent may filter on it) or `returns` (an agent may +# read it back). Each one is a judgement about exposure, not a fact about the +# table: leave out anything a model should never see. +class <%= class_name %>Tools < ActiveAgent::SchemaTools + model <%= class_name %> + + filterable :id +<% if suggested_columns.any? -%> + # filterable <%= suggested_columns.map { |name, _type| ":#{name}" }.join(", ") %> +<% else -%> + # filterable :status, :owner_id # columns an agent may filter on +<% end -%> + + returns :id +<% if suggested_columns.any? -%> + # returns <%= suggested_columns.map { |name, _type| ":#{name}" }.join(", ") %> +<% else -%> + # returns :id, :title, :status # columns an agent may read back +<% end -%> +<% if suggested_columns.any? -%> + # + # Columns and their types: +<% suggested_columns.each do |name, type| -%> + # <%= name.ljust(24) %> <%= type %> +<% end -%> +<% end -%> +<% if secret_columns.any? -%> + # + # Not suggested, and not to be added: <%= secret_columns.join(", ") %>. +<% end -%> + +<% if policy? -%> + # Every read runs through <%= policy_class_name %>::Scope for the acting + # user, so an agent sees exactly what that user could see. + scope_by_policy +<% else -%> + # Every read should run through a relation scoped to the acting user. Without + # a scope the tools read the whole table. Return <%= class_name %>.none for an + # actor with no access; never widen for a nil actor. + # scope { |actor| actor ? <%= class_name %>.where(owner: actor) : <%= class_name %>.none } +<% end -%> +end +<% end -%> diff --git a/test/generators/active_agent/schema_tools_generator_test.rb b/test/generators/active_agent/schema_tools_generator_test.rb new file mode 100644 index 00000000..53efd861 --- /dev/null +++ b/test/generators/active_agent/schema_tools_generator_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "test_helper" +require "generators/active_agent/schema_tools/schema_tools_generator" + +class ActiveAgent::Generators::SchemaToolsGeneratorTest < Rails::Generators::TestCase + tests ActiveAgent::Generators::SchemaToolsGenerator + destination Rails.root.join("tmp/generators") + setup :prepare_destination + + test "writes a tools class that exposes only id until a column is chosen" do + run_generator [ "post" ] + + assert_file "app/agent_tools/post_tools.rb" do |content| + assert_match(/class PostTools < ActiveAgent::SchemaTools/, content) + assert_match(/^\s+model Post$/, content) + assert_match(/^\s+filterable :id$/, content) + assert_match(/^\s+returns :id$/, content) + end + end + + test "lists the model's columns, commented out, with their types" do + run_generator [ "Post" ] + + assert_file "app/agent_tools/post_tools.rb" do |content| + filterable = content[/^\s+# filterable (.+)$/, 1] + returns = content[/^\s+# returns (.+)$/, 1] + %w[:title :content :published :published_at :user_id :created_at :updated_at].each do |column| + assert_includes filterable, column + assert_includes returns, column + end + assert_no_match(/:id\b/, filterable, "id is already declared, not a suggestion") + assert_match(/#\s+published\s+boolean/, content) + assert_match(/#\s+title\s+string/, content) + assert_no_match(/^\s+filterable :title/, content, "a column must be chosen, not pre-selected") + end + end + + test "accepts the Tools suffix and a namespace" do + run_generator [ "PostTools" ] + assert_file "app/agent_tools/post_tools.rb", /class PostTools < ActiveAgent::SchemaTools/ + + run_generator [ "admin/post" ] + assert_file "app/agent_tools/admin/post_tools.rb" do |content| + assert_match(/class Admin::PostTools < ActiveAgent::SchemaTools/, content) + assert_match(/model Admin::Post/, content) + end + end + + test "suggests a scope block when no policy exists, and scope_by_policy on request" do + run_generator [ "post" ] + assert_file "app/agent_tools/post_tools.rb" do |content| + assert_match(/# scope \{ \|actor\| actor \? Post\.where\(owner: actor\) : Post\.none \}/, content) + assert_no_match(/^\s+scope_by_policy/, content) + end + + run_generator [ "post", "--policy", "--force" ] + assert_file "app/agent_tools/post_tools.rb" do |content| + assert_match(/^\s+scope_by_policy$/, content) + assert_match(/PostPolicy::Scope/, content) + end + end + + test "a model that does not exist yet still gets a file, with placeholders" do + run_generator [ "widget" ] + + assert_file "app/agent_tools/widget_tools.rb" do |content| + assert_match(/model Widget/, content) + assert_match(/# filterable :status, :owner_id/, content) + assert_match(/# returns :id, :title, :status/, content) + end + end + + test "secret-shaped columns are never suggested" do + column = Struct.new(:name, :type) + secretive = Class.new(ActiveRecord::Base) do + self.table_name = "users" + def self.name = "Member" + define_singleton_method(:columns) { super() + [ column.new("password_digest", :string), column.new("api_token", :string) ] } + end + Object.const_set(:Member, secretive) + + run_generator [ "member" ] + + assert_file "app/agent_tools/member_tools.rb" do |content| + assert_no_match(/:password_digest|:api_token/, content) + assert_match(/Not suggested, and not to be added: password_digest, api_token/, content) + assert_includes content[/^\s+# returns (.+)$/, 1], ":email" + end + ensure + Object.send(:remove_const, :Member) if Object.const_defined?(:Member) + end +end From e3ce18b51d83c32989be51bac0c4e6a6b83d7729 Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Sat, 12 Sep 2026 12:29:39 -0700 Subject: [PATCH 2/2] docs(changelog): restore this branch's entry dropped in the last merge --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13076a03..4da90c2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`rails generate active_agent:schema_tools Reservation`** writes a starter + `ActiveAgent::SchemaTools` class under `app/agent_tools`. It exposes nothing + beyond `id` until a column is moved into `filterable` or `returns`; every + column the model has is listed, commented out, with its type, so the + allowlist is a review step rather than a blank page, and columns that look + like secrets are left off the list. Reads are scoped through + `Policy::Scope` when it exists (`--policy` / `--no-policy` decide + explicitly). This is #440's second option: the roster is still declared, + once, but the declaration is no longer written from scratch. (#440) + ### Fixed - **The caller can no longer be named by the model, or by the client.**