Schema-derived agent tools: generate a bounded tool roster from an ActiveRecord model - #435
Conversation
Agents running in the dashboard only have generic tools (fetch_url,
web_search, calculate). Asked a question about the host application's own
data they have nothing to call, so they answer from the prompt and invent
the rest.
SchemaTools lets a host declare, per model, which columns an agent may
filter on and which it may read back, and generates a fixed roster of
read-only function-calling tools (find_*, count_*, get_*) from that
declaration.
class TicketTools < ActiveAgent::SchemaTools
model Ticket
filterable :client, :assignee, :status
returns :id, :subject, :status, :due_date
scope { |actor| TicketPolicy::Scope.new(actor, Ticket).resolve }
end
Design properties:
* Allowlist-gated. An undeclared filter column is rejected, not silently
dropped — dropping it would answer a broader question than was asked
while looking like a success.
* Fixed roster via define_method, not method_missing, because the
dashboard, MCP tools/list, and eval `tools:` expectations all need to
enumerate the roster before any call happens.
* Bounded results. Default limit, hard cap, and a visible truncated flag.
* Authorization is the host's seam: the scope block receives the actor and
returns a relation. No auth is implemented here; an absent scope runs
unscoped, which is a legitimate host choice.
Parameter schemas come from ActiveAgent::SchemaGenerator rather than a
local type map, so column types, formats, and inclusion-validator enums are
described consistently.
Read-only by design — no create/update/delete tools are generated.
Registration into the dashboard is deliberately left to a follow-up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01He34kWksjpqPPDpCCqJq2C
…table SchemaTools shipped the DSL but nothing consumed it: an agent could list a generated tool name, `definitions_for` returned [], the model received no schemas, and it invented tool names in prose while the run scored 0.0 on expected_tools. A wiring failure that reads as a model failure — the same shape as #425 and #433. Adds the registration the proposal specified, following the mcp_catalog precedent: ActionAgent.configure do |config| config.schema_tools = [TicketTools, TaskTools, MilestoneTools] end From that declaration: - `Agent.available_tools` offers each generated tool alongside the built-ins, so they render as selectable cards in the editor rather than read-only chips. AVAILABLE_TOOLS keeps its meaning as the built-in set, and the value is computed per call — a host's tool classes are autoloaded and reloaded in development, so a memoised list would be empty at boot or stale after. - AgentToolbox resolves and dispatches them, passing `actor:` through untouched including nil. The host's scope block decides what an unattributed run may read; the engine never widens it. - A new agent named after a model starts with that model's tools selected (Reservation -> ReservationTools -> ReservationAgent). A default, never a restriction: any agent may enable any tool, and an explicit selection — including a deliberate empty one — is never overwritten. Class names may be given as strings and are resolved lazily, because a host declares these in an initializer that runs before its own autoloading. The convention matches on letters only rather than reusing telemetry_agent_class, which runs parameterize.camelize and turns an already camelised "TicketAgent" into "Ticketagent" — matching nothing, while "Milestone Agent" happens to survive. Normalising both sides makes "TicketAgent", "Ticket Agent" and "ticket_agent" all match. The editor labels a generated tool by its bare function name in mono rather than capitalising the identifier into "Find_tickets". Bundle rebuilt. Closes #438. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PHUS1KBqDgUCAmpn2o2zxm
Update — this PR now carries the registration seam too (
|
Two pieces of boilerplate a host had to write, removed. Both are responses to #440: the config restated things the framework already knew. **Declaring twice.** `config.schema_tools` now defaults to nil, meaning "discover", and `schema_tools_path` ("app/agent_tools") is scanned for SchemaTools subclasses. Adding a tool is adding a file; nothing names it a second time. An explicit array still wins, and a nil path disables discovery for a host that wants the declaration to be the only source. The files are loaded before reading `descendants`, because in development nothing has referenced those constants yet — the list would otherwise be empty at boot and fill in only once something happened to touch them. Anonymous classes are deliberately excluded from discovery. They work when declared explicitly, but a runtime-built class cannot supersede itself, so discovery would accumulate one per reload with no way to evict the stale ones. Measured before excluding them: two classes built across two reload cycles, both retained. **Writing the policy block.** `scope_by_policy` resolves Reservation -> ReservationPolicy::Scope and calls `.new(actor, model).resolve`. Verified end to end against a host: owner sees all 6 tickets, a partner sees only their own client, a nil actor sees none — the same boundary the hand-written block enforced. It is opt-in rather than automatic. Silently scoping a class that declared no scope would change what an existing tool returns, and a host may run its authorization somewhere other than a Pundit-shaped policy. A missing or mistyped policy raises at declaration rather than quietly reading the whole table. Verified: a host app with no `config.schema_tools` at all serves all nine generated tools in the editor payload; its suite is green (2495 runs, 0 failures) against this branch. Refs #440. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PHUS1KBqDgUCAmpn2o2zxm
Follow-up —
|
Two fixes to the same family of papercut. **VCR filters registered against unset env vars.** Every filter_sensitive_data block read an ENV var directly, and CI sets none of them — cassettes replay without credentials — so on CI each block returned nil. VCR registers the filter anyway and then substitutes an empty string through every request and response it handles, which surfaces far from the cause as an intermittent NoMethodError: undefined method `each_key' for nil webmock/util/hash_validator.rb:12:in `validate_keys' while WebMock builds a stubbed response. It reads as a flaky cassette rather than a configuration problem, and it cost a re-run on PR activeagents#435 and an investigation on activeagents#432 to rule out. A filter_env helper now registers a filter only when the variable actually holds something, so replay is deterministic on CI and redaction still applies when recording with real keys. This also removes the one-off `if ENV[...]` guard that already existed for AZURE_OPENAI_RESOURCE — that guard was the right instinct applied to exactly one of ten filters. **The MCP plural did not inflect.** An acronym only matches a whole word, so with `inflect.acronym "MCP"` alone, `mcps` still camelizes to `Mcps`, and a constant spelled `MCPs` underscores back to `mc_ps_examples_test` — a name no file has. Registering the plural as its own acronym makes both directions agree (mcps <-> MCPs), and the two test classes that spelled it `Mcps` are renamed to match. Note `mcps:` stays lowercase as a prompt parameter, and `Mcp-Session-Id` stays as-is: that is the spelling the MCP specification gives the header. Engine suite 340 runs / 0 failures; docs tests match their pre-change baseline exactly (15 runs, 9 local credential errors before and after). RuboCop clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01He34kWksjpqPPDpCCqJq2C
Introduced with the scope_by_policy tests in #435 and caught by CI on the release branch. Autocorrected; rubocop is clean on all 533 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PHUS1KBqDgUCAmpn2o2zxm
The gap
Agents running in the dashboard only get generic tools —
fetch_url,browse_page,web_search,calculate,render_ui,save_memory, browser control,call_agent. None of them models the host application's domain.So an agent asked "which clients have overdue tickets?" has nothing to call. It answers from the prompt and fabricates the rest.
ActiveAgent::SchemaGeneratoralready turns a model into JSON Schema — that's the parameter-schema half. This PR adds the missing half: a layer that turns a model plus a declared boundary into callable, enumerable tools.The DSL
Definitions come out in the same flat
{name:, description:, parameters:}function-calling shapeAgentToolbox::DEFINITIONSalready uses, so wiring is a drop-in later.Design properties
1. Allowlist-gated, never open-ended
Only
filterablecolumns may be filtered on; onlyreturnscolumns are ever read back.An undeclared column is rejected, not silently dropped. That distinction matters: silently ignoring an unknown filter answers a broader question than the model asked while still looking like a success — which is exactly how a model ends up confidently reporting the unfiltered set as if it were filtered. Rejecting is also what keeps the boundary real; without it a model could filter on
users.password_digestone character at a time and read a secret out of the row counts.The projection is enforced twice — in SQL and again in Ruby. The Ruby side is the one that actually guarantees it: a
scopeblock ending inincludesor a rawselectcan hand back a record carrying more columns than were asked for.2. Fixed roster via
define_method, NOTmethod_missingDeliberate, and load-bearing. Every consumer needs to enumerate the roster before any call happens:
tools/listmust answer without being told a name first,| tools:expectations assert against a known set.method_missingcan answer "do you respond to this?" but cannot answer "what is there?". Tools are built at declaration time and exposed via.tool_definitions/.tool_names/.tool?.3. Authorization is the host's seam — not implemented here
scopetakes a block receiving the caller's actor and returning a relation; generated tools query through it. SchemaTools does not know what an actor is and deliberately does not try to authorize — hosts have Pundit, CanCan, or nothing, and a framework guess would be either wrong or in the way.Omitting
scoperuns unscoped. That is a legitimate host choice for a single-tenant or already-trusted context, not a default to fight.One related decision:
get_*usesfind_bythrough the scoped relation, so a record the actor can't see reads as "not found" rather than as a 404-vs-403 signal the model could use to probe for existence.4. Results are bounded
A
find_*with no filters would otherwise select the whole table into a prompt. Default limit 25, hard cap 100, and atruncatedflag so the model can tell "these are all of them" from "these are the first 25". An oversizedlimitis clamped rather than rejected — a model asking for 1000 rows wants as many as it can get, and an error would just make it ask again.5. Parameter schemas come from
SchemaGeneratorNot a local type map. This is why
rolearrives with itsenumpopulated fromUser's inclusion validator — hand-rolled type mapping wouldn't know that.Scope discipline
lib/active_agent/— framework-level and host-agnostic, not inactionagent/.AgentToolboxor any dashboard wiring. Registration is a deliberate follow-up; this PR is self-contained.User/Postmodels — no invented domain models.Tests
36 new tests, 110 assertions, in
test/schema_tools_test.rb, covering the security boundary explicitly: undeclared filter rejected, undeclared return column not leaked, limit enforced, scope block invoked with the actor, absent scope runs unscoped.Verified the tests actually fail without the implementation. Removing the file is only a
LoadError, which proves the file is required but not that each assertion is load-bearing — so each boundary was sabotaged independently in the restored file:scopeblock ignored (always unscoped)Full suite: 1845 → 1881 runs (+36, exactly the new tests), 0 failures. The 253 errors are pre-existing and unchanged — all are
Missing credentials ... OPENAI_API_KEYfrom this local environment having no.env.test, unrelated to this change.actionagentsuite: 340 runs, 1780 assertions, 0 failures. RuboCop clean on all three changed files.For a reviewer to scrutinize
belongs_tonames resolve to foreign keys —filterable :clientbecomesclient_idin the tool signature. Convenient, but it means the model filters by raw id, which it can only know from a prior tool result. Filtering by a human-readable association attribute would be friendlier and is a plausible follow-up.{ error: ... }rather than raised, matching theAgentToolbox.callcontract, so a model that guesses a column name gets a correction it can act on instead of killing the run. Genuine programming errors still raise.inheritedcopies declarations by value so a subclass can't silently widen its parent's allowlist — worth confirming that's the semantics you want versus forbidding subclassing outright.find_*. Combined with a limit, "first 25" is currently whatever the DB returns. Adding a declared-allowlistorderis the obvious next increment.🤖 Generated with Claude Code
https://claude.ai/code/session_01He34kWksjpqPPDpCCqJq2C