Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ 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
`<Model>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)
- **An agent knows who it is running for, so an authorization gem has
something to decide against.** `ActiveAgent::Base#current_user` carries the
caller, assigned by whatever authenticated the call
Expand Down
38 changes: 38 additions & 0 deletions docs/actions/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<records>`, `count_<records>`, `get_<record>` — 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 `<Model>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:
Expand Down
22 changes: 22 additions & 0 deletions lib/generators/active_agent/schema_tools/USAGE
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Description:
Writes a starter ActiveAgent::SchemaTools class for a model under
app/agent_tools. The class generates find_<records>, count_<records> and
get_<record> 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 <Model>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.
84 changes: 84 additions & 0 deletions lib/generators/active_agent/schema_tools/schema_tools_generator.rb
Original file line number Diff line number Diff line change
@@ -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 <Model>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
Original file line number Diff line number Diff line change
@@ -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 -%>
93 changes: 93 additions & 0 deletions test/generators/active_agent/schema_tools_generator_test.rb
Original file line number Diff line number Diff line change
@@ -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