activeagent 1.5.2 → 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 +136 -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 +27 -11
- 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/schema_tools.rb +65 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +3 -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 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1838cd05b12c4bc56330e8c4b4ec98295e885990bc67b600fefaa401f2543593
|
|
4
|
+
data.tar.gz: dcd3590de72aef1a8059a6fb03d9303672e73260a59c33e9244ccf326d48b7b6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1534a469b0eeec15765b71e7b43e8362660d9fc7610531c757acb882e130a95cc938a2033b2d0982233558123a0b162d6efb9c51da3e7038d0f0f1d6fcfe8d9e
|
|
7
|
+
data.tar.gz: eb849f183afd357f381836947891c8029d5ca4772f2e4bf1fda3fedb30ebb7f71db1021f9f94a8ba62337a99493b54b0124f9f0e28d8342fde4182d8ebf02411
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,142 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.6.0] - 2026-09-14
|
|
11
|
+
|
|
12
|
+
Releases `activeagent` and `actionagent` 1.6.0 from one tag.
|
|
13
|
+
|
|
14
|
+
A minor, not a patch. The cycle that began after 1.5.2 gives an agent a
|
|
15
|
+
caller — `current_user`, carried from whatever authenticated the call into
|
|
16
|
+
every `before_action`, every tool, every delegated sub-agent, every run over
|
|
17
|
+
MCP and every evaluation replay — so an authorization gem has something to
|
|
18
|
+
decide against. Around it: schema tools defined at runtime rather than only
|
|
19
|
+
in a file, a generator that writes the first one, those tools served
|
|
20
|
+
directly over MCP, and an evaluation that calls a fabricated answer a fault
|
|
21
|
+
instead of grading it as an honest gap. That is new public surface in both
|
|
22
|
+
gems, which is a minor under semver even though 1.5.2 shipped a feature as a
|
|
23
|
+
patch.
|
|
24
|
+
|
|
25
|
+
Two notes for upgrades. `tools_succeeded` is now awarded only for a tool the
|
|
26
|
+
scenario expected, so a suite that was quietly scoring wrong-tool runs as
|
|
27
|
+
partial successes will report lower — read the first run as a corrected
|
|
28
|
+
baseline. And `actor:` is now stripped from tool arguments and from
|
|
29
|
+
`params[params][actor]`: the caller is a property of the run, set once by
|
|
30
|
+
whatever authenticated it, and can no longer be named by the model or by a
|
|
31
|
+
client.
|
|
32
|
+
|
|
33
|
+
The engine's floor on the framework (`activeagent >= 1.4`) is unchanged and
|
|
34
|
+
still correct: 1.6.0 satisfies it.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **An evaluation replay runs as the evaluation's owner.** The scenario runner
|
|
39
|
+
handed `Agent#test_execute` no caller, so every tool a replay called ran
|
|
40
|
+
unattributed and a host scope answered empty — the suite graded an agent
|
|
41
|
+
that never saw a row. When agents are owned per user, a replay now runs as
|
|
42
|
+
the user who owns the evaluation, as a run over MCP runs as the key's
|
|
43
|
+
owner. A multi-tenant install still replays unattributed (an account is who
|
|
44
|
+
is billed, not who is allowed) unless a host adapter runs the suite itself.
|
|
45
|
+
- **The MCP facade serves the host's schema tools directly.** `tools/list`
|
|
46
|
+
at `POST <mount>/mcp` now offers every tool the dashboard's discovered
|
|
47
|
+
`ActiveAgent::SchemaTools` classes generate — `find_<records>`,
|
|
48
|
+
`count_<records>`, `get_<record>` — beside the `run_<slug>` agents, each
|
|
49
|
+
with its own parameter schema, and `tools/call` runs one as the key's
|
|
50
|
+
caller through the host's own scope, exactly as it would inside an agent
|
|
51
|
+
run. A client that only needs the rows no longer has to ask an agent for
|
|
52
|
+
them. A boundary violation is a tool result with `isError`, a refusal from
|
|
53
|
+
the host's scope is a JSON-RPC `-32003`, and neither the execution switch
|
|
54
|
+
nor the execution quota applies, because nothing generates. Set
|
|
55
|
+
`ActionAgent.mcp_schema_tools = false` to keep the tools reachable only
|
|
56
|
+
through agents. Closes #439.
|
|
57
|
+
- **A delegated run inherits its parent's caller.** `delegate_to` hands the
|
|
58
|
+
sub-agent the parent's `current_user` before its action runs, so its own
|
|
59
|
+
`before_action` callbacks and any scope its tools read through decide
|
|
60
|
+
against the same person. A parent authorized as one user no longer hands
|
|
61
|
+
its specialists an unattributed run — which a correctly written host scope
|
|
62
|
+
reads as "no access", a wrong answer wearing a right one's clothes. A
|
|
63
|
+
parent with no caller still delegates an unattributed run, never someone
|
|
64
|
+
else's.
|
|
65
|
+
- **`rails generate active_agent:schema_tools Reservation`** writes a starter
|
|
66
|
+
`ActiveAgent::SchemaTools` class under `app/agent_tools`. It exposes nothing
|
|
67
|
+
beyond `id` until a column is moved into `filterable` or `returns`; every
|
|
68
|
+
column the model has is listed, commented out, with its type, so the
|
|
69
|
+
allowlist is a review step rather than a blank page, and columns that look
|
|
70
|
+
like secrets are left off the list. Reads are scoped through
|
|
71
|
+
`<Model>Policy::Scope` when it exists (`--policy` / `--no-policy` decide
|
|
72
|
+
explicitly). This is #440's second option: the roster is still declared,
|
|
73
|
+
once, but the declaration is no longer written from scratch. (#440)
|
|
74
|
+
- **An agent knows who it is running for, so an authorization gem has
|
|
75
|
+
something to decide against.** `ActiveAgent::Base#current_user` carries the
|
|
76
|
+
caller, assigned by whatever authenticated the call
|
|
77
|
+
(`MyAgent.as(current_user).ask(...)`) and readable from every
|
|
78
|
+
`before_action`, so Pundit, CanCanCan or Action Policy authorize an agent
|
|
79
|
+
the way they authorize a controller. A refusal inside a tool call is
|
|
80
|
+
returned to the model as `{ error: ... }` so it can say it is not allowed;
|
|
81
|
+
a refusal anywhere else is raised to the caller. `denies_with` names a
|
|
82
|
+
gem's own error as a refusal.
|
|
83
|
+
- **The dashboard fills that seam.** A run records the caller that started it
|
|
84
|
+
(a Global ID, so a worker on another machine authorizes as the same
|
|
85
|
+
person), passes it to every tool as `actor:`, and runs the agent through
|
|
86
|
+
`as(actor)` — which is what a `SchemaTools` `scope` block has been waiting
|
|
87
|
+
for since 1.5.0. `ActionAgent.agent_actor_resolver` overrides who that is.
|
|
88
|
+
- **Agents reached over MCP run as the key's caller** rather than
|
|
89
|
+
unattributed, and an agent that refuses answers as a JSON-RPC error
|
|
90
|
+
(`-32003`) instead of as an empty result.
|
|
91
|
+
- **Schema tools can be defined at runtime.** `ActiveAgent::SchemaTools.define(Reservation,
|
|
92
|
+
filterable:, returns:, scope: | policy:)` builds the same bounded roster a
|
|
93
|
+
file under `app/agent_tools` would — same allowlists, same `call`, named
|
|
94
|
+
`ReservationTools` for logs — from a declaration held anywhere: a table, a
|
|
95
|
+
dashboard form, a test. The class is registered under its model and a
|
|
96
|
+
redefinition replaces the previous one, so a registry rebuilt on every
|
|
97
|
+
change holds one class per model; `undefine` drops it. The dashboard
|
|
98
|
+
discovers registry entries beside the files and lets a runtime definition
|
|
99
|
+
supersede a file for the same model. What is persisted, and where, stays the
|
|
100
|
+
host's decision; this is the seam a persisted declaration builds on. (#441)
|
|
101
|
+
- **A fabricated answer is now a fault.** `Diagnosis` raises `ungrounded_answer`
|
|
102
|
+
when an agent that had tools called none, did not say it could not answer,
|
|
103
|
+
and still stated specifics — a count, a record id, a date — that no tool
|
|
104
|
+
supplied. Where the scenario names an expected tool, `expected_tool_not_called`
|
|
105
|
+
says the same thing in its summary and carries `ungrounded: true`, so an
|
|
106
|
+
invented answer no longer reads like an honest gap. Both reach the judge,
|
|
107
|
+
which is what turns them into a suggested tool. An agent with no tools at all
|
|
108
|
+
is not flagged: it answers from its instructions by design, and whether that
|
|
109
|
+
is acceptable is the judge's grade, not a mechanical one. (#433)
|
|
110
|
+
|
|
111
|
+
### Changed
|
|
112
|
+
|
|
113
|
+
- **A wrong tool no longer outscores no tool.** `tools_succeeded` is awarded
|
|
114
|
+
only for a tool the scenario expected (or any tool when it expects none):
|
|
115
|
+
a tool that ran without erroring was evidence of the task only by accident,
|
|
116
|
+
and a scenario that called the wrong tool scored higher than one that called
|
|
117
|
+
nothing. (#433)
|
|
118
|
+
- **The judge reads more of a scenario's notes** — 1,500 characters rather
|
|
119
|
+
than 300 — because a suite's notes are often its rubric and the "must not"
|
|
120
|
+
clause tends to come last. (#433)
|
|
121
|
+
|
|
122
|
+
### Fixed
|
|
123
|
+
|
|
124
|
+
- **The caller can no longer be named by the model, or by the client.**
|
|
125
|
+
`actor:` reached `AgentToolbox.call` in the same keyword namespace as the
|
|
126
|
+
arguments a provider parsed out of a model's tool call, and
|
|
127
|
+
`params[params][actor]` in an execute request would have won over the
|
|
128
|
+
controller's own. Both are now stripped: the caller is a property of the
|
|
129
|
+
run, set once by whatever authenticated it. Scoped `SchemaTools` reads are
|
|
130
|
+
also excluded from the tool-result cache, so one caller's rows are never
|
|
131
|
+
replayed for the next.
|
|
132
|
+
- **A superseded runtime tool class is no longer offered twice.** Discovery
|
|
133
|
+
read every `SchemaTools` subclass out of `descendants`, where a class built
|
|
134
|
+
at runtime stays until it is collected, so rebuilding a model's tools
|
|
135
|
+
accumulated stale duplicates. Runtime-built classes are now read from the
|
|
136
|
+
registry only. (#441)
|
|
137
|
+
- **A persisted model selection re-runs under the provider it ran under.**
|
|
138
|
+
`Evals::ModelSpec.parse_all` re-parsed a round-tripped spec from its label,
|
|
139
|
+
so `anthropic/claude-sonnet-4.5` run through OpenRouter came back as
|
|
140
|
+
Anthropic's own `claude-sonnet-4.5` as soon as that provider's gem was
|
|
141
|
+
installed — and the re-run failed for want of an Anthropic credential. A
|
|
142
|
+
hash naming both `provider` and `model` is now rebuilt as it was; a bare
|
|
143
|
+
label is still parsed. The dashboard's "re-run" of a saved selection is
|
|
144
|
+
the path this fixes.
|
|
145
|
+
|
|
10
146
|
## [1.5.2] - 2026-09-11
|
|
11
147
|
|
|
12
148
|
Releases `activeagent` and `actionagent` 1.5.2 from one tag.
|
data/lib/active_agent/base.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "active_support/core_ext/hash/except"
|
|
|
4
4
|
require "active_support/core_ext/module/anonymous"
|
|
5
5
|
require "active_support/core_ext/string/inflections"
|
|
6
6
|
|
|
7
|
+
require "active_agent/concerns/authorization"
|
|
7
8
|
require "active_agent/concerns/callbacks"
|
|
8
9
|
require "active_agent/concerns/delegation"
|
|
9
10
|
require "active_agent/concerns/observers"
|
|
@@ -43,6 +44,7 @@ module ActiveAgent
|
|
|
43
44
|
include AbstractController::Caching
|
|
44
45
|
|
|
45
46
|
include Callbacks
|
|
47
|
+
# After Rescue: the refusal handler is registered with rescue_from.
|
|
46
48
|
include Delegation
|
|
47
49
|
include Parameterized
|
|
48
50
|
include Provider
|
|
@@ -52,6 +54,11 @@ module ActiveAgent
|
|
|
52
54
|
include Tooling
|
|
53
55
|
include View
|
|
54
56
|
|
|
57
|
+
# Last of the behaviour concerns: its rescue_from must sit on top of the
|
|
58
|
+
# handlers an agent registers for its own authorization gem, so a host
|
|
59
|
+
# mapping Pundit::NotAuthorizedError keeps deciding what a refusal means.
|
|
60
|
+
include Authorization
|
|
61
|
+
|
|
55
62
|
include Observers
|
|
56
63
|
include Previews
|
|
57
64
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
# Raised when an agent refuses a call on the current caller's behalf.
|
|
5
|
+
#
|
|
6
|
+
# Hosts usually raise their authorization gem's own error instead
|
|
7
|
+
# (+Pundit::NotAuthorizedError+, +CanCan::AccessDenied+) and name it with
|
|
8
|
+
# {Authorization::ClassMethods#denies_with}; this exists so an agent that
|
|
9
|
+
# has no gem still has something to raise, and so the framework has one
|
|
10
|
+
# class to describe a refusal with.
|
|
11
|
+
class NotAuthorized < StandardError
|
|
12
|
+
# @return [Symbol, String, nil] the action or tool that was refused
|
|
13
|
+
attr_reader :action
|
|
14
|
+
# @return [Object, nil] the caller the refusal was decided against
|
|
15
|
+
attr_reader :actor
|
|
16
|
+
|
|
17
|
+
def initialize(message = nil, action: nil, actor: nil)
|
|
18
|
+
@action = action
|
|
19
|
+
@actor = actor
|
|
20
|
+
super(message || default_message)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def default_message
|
|
26
|
+
who = actor.nil? ? "an unauthenticated caller" : "the current caller"
|
|
27
|
+
what = action ? "`#{action}`" : "this agent"
|
|
28
|
+
"#{who} is not allowed to call #{what}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Carries the caller an agent runs on behalf of, so an agent's callbacks can
|
|
33
|
+
# authorize with whatever the host app already uses.
|
|
34
|
+
#
|
|
35
|
+
# An agent reached over MCP, from a dashboard run, or from a controller is
|
|
36
|
+
# acting *for someone*. Without that someone, an authorization gem has
|
|
37
|
+
# nothing to decide against: a Pundit scope handed +nil+ correctly resolves
|
|
38
|
+
# to the empty set, so a perfectly wired agent answers "there are no
|
|
39
|
+
# tickets" instead of refusing — a wrong answer that reads like a true one.
|
|
40
|
+
#
|
|
41
|
+
# == The seam
|
|
42
|
+
#
|
|
43
|
+
# {#current_user} is assigned by whatever authenticated the call and is
|
|
44
|
+
# readable from every callback and action:
|
|
45
|
+
#
|
|
46
|
+
# class TicketAgent < ApplicationAgent
|
|
47
|
+
# before_action :authorize_tickets!
|
|
48
|
+
#
|
|
49
|
+
# def find_tickets(**filters)
|
|
50
|
+
# TicketTools.call("find_tickets", actor: current_user, **filters)
|
|
51
|
+
# end
|
|
52
|
+
#
|
|
53
|
+
# private
|
|
54
|
+
#
|
|
55
|
+
# def authorize_tickets!
|
|
56
|
+
# raise ActiveAgent::NotAuthorized.new(action: action_name, actor: current_user) unless
|
|
57
|
+
# TicketPolicy.new(current_user, Ticket).index?
|
|
58
|
+
# end
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
# TicketAgent.as(current_user).find_tickets.generate_now
|
|
62
|
+
#
|
|
63
|
+
# Any gem works, because the framework never interprets the actor — Pundit's
|
|
64
|
+
# +authorize+/+policy_scope+, CanCanCan's +can?+, Action Policy's
|
|
65
|
+
# +authorize!+, or a plain predicate. +before_action+ is the same
|
|
66
|
+
# +AbstractController+ chain a controller uses, including +only:+/+except:+,
|
|
67
|
+
# so a roster can be authorized tool by tool.
|
|
68
|
+
#
|
|
69
|
+
# == It is never model input
|
|
70
|
+
#
|
|
71
|
+
# The actor is an attribute of the run, set out of band by the caller. It is
|
|
72
|
+
# deliberately not part of +params+ and not a tool argument: everything a
|
|
73
|
+
# model emits is attacker-reachable through the documents it reads, and an
|
|
74
|
+
# actor a model can name is not an authorization boundary. Assigning it is
|
|
75
|
+
# the caller's job, once, before the generation starts.
|
|
76
|
+
#
|
|
77
|
+
# == What a refusal does
|
|
78
|
+
#
|
|
79
|
+
# A refusal inside a *tool call* is returned to the model as an error result
|
|
80
|
+
# ({ error: ... }), so it can tell the user it is not allowed to look rather
|
|
81
|
+
# than dying mid-run or, worse, reporting an empty result set as fact. A
|
|
82
|
+
# refusal anywhere else — the action the caller asked for — is raised, so
|
|
83
|
+
# the MCP client or controller that asked gets an error instead of an
|
|
84
|
+
# answer that silently covers less ground than it appears to.
|
|
85
|
+
#
|
|
86
|
+
# {ClassMethods#denies_with} is how a gem's own error joins that rule:
|
|
87
|
+
#
|
|
88
|
+
# class ApplicationAgent < ActiveAgent::Base
|
|
89
|
+
# denies_with Pundit::NotAuthorizedError
|
|
90
|
+
# end
|
|
91
|
+
module Authorization
|
|
92
|
+
extend ActiveSupport::Concern
|
|
93
|
+
|
|
94
|
+
included do
|
|
95
|
+
# The caller this generation runs on behalf of, or nil when it runs
|
|
96
|
+
# unattributed. Whatever the host uses as an actor: a User, an API
|
|
97
|
+
# key's owner, a service account.
|
|
98
|
+
attr_accessor :current_user
|
|
99
|
+
|
|
100
|
+
# Exception classes that mean "the caller may not do this". Declared
|
|
101
|
+
# rather than guessed: only the host knows which of its errors are a
|
|
102
|
+
# refusal and which are a bug.
|
|
103
|
+
class_attribute :authorization_errors, instance_writer: false,
|
|
104
|
+
default: [ ActiveAgent::NotAuthorized ].freeze
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class_methods do
|
|
108
|
+
# Treats +classes+ as refusals, so raising one inside a tool call
|
|
109
|
+
# reports to the model instead of ending the run.
|
|
110
|
+
#
|
|
111
|
+
# @param classes [Array<Class>] exception classes from an authorization gem
|
|
112
|
+
# @return [Array<Class>] every class now treated as a refusal
|
|
113
|
+
#
|
|
114
|
+
# @example
|
|
115
|
+
# denies_with Pundit::NotAuthorizedError, CanCan::AccessDenied
|
|
116
|
+
def denies_with(*classes)
|
|
117
|
+
self.authorization_errors = (authorization_errors | classes.flatten).freeze
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Runs the agent on behalf of +actor+.
|
|
121
|
+
#
|
|
122
|
+
# @param actor [Object, nil] the caller, or nil to run unattributed
|
|
123
|
+
# @return [ActiveAgent::Parameterized::Agent] a proxy carrying the actor
|
|
124
|
+
#
|
|
125
|
+
# @example
|
|
126
|
+
# SupportAgent.as(current_user).answer(question).generate_now
|
|
127
|
+
#
|
|
128
|
+
# @example With parameters
|
|
129
|
+
# SupportAgent.as(current_user).with(locale: :en).answer(question)
|
|
130
|
+
def as(actor)
|
|
131
|
+
ActiveAgent::Parameterized::Agent.new(self, {}, actor: actor)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Whether a refusal right now would be reported to the model rather than
|
|
136
|
+
# raised to the caller.
|
|
137
|
+
# @return [Boolean]
|
|
138
|
+
def tool_call?
|
|
139
|
+
@_active_agent_tool_call ||= false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Marks the block as a tool call, so a refusal inside it becomes a result
|
|
143
|
+
# the model can read. Nested calls keep the outer marking.
|
|
144
|
+
# @api private
|
|
145
|
+
def with_tool_call
|
|
146
|
+
previous = @_active_agent_tool_call
|
|
147
|
+
@_active_agent_tool_call = true
|
|
148
|
+
|
|
149
|
+
result = yield
|
|
150
|
+
# A host's own rescue_from handler runs inside `process`, and
|
|
151
|
+
# ActiveSupport::Rescuable answers with the exception itself — so a
|
|
152
|
+
# refusal arrives either raised or returned, and both mean the same
|
|
153
|
+
# thing here.
|
|
154
|
+
refusal?(result) ? refused(result) : result
|
|
155
|
+
rescue *authorization_errors => exception
|
|
156
|
+
refused(exception)
|
|
157
|
+
ensure
|
|
158
|
+
@_active_agent_tool_call = previous
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def refusal?(value)
|
|
164
|
+
value.is_a?(Exception) && authorization_errors.any? { |klass| value.is_a?(klass) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def refused(exception)
|
|
168
|
+
logger&.info("[#{self.class.name}] refused #{action_name}: #{exception.message}")
|
|
169
|
+
{ error: exception.message }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -110,9 +110,28 @@ module ActiveAgent
|
|
|
110
110
|
class Agent
|
|
111
111
|
# @param agent [Class] the agent class to proxy
|
|
112
112
|
# @param params [Hash] the parameters to pass to agent instances
|
|
113
|
-
|
|
113
|
+
# @param actor [Object, nil] the caller the generation runs on behalf
|
|
114
|
+
# of (see ActiveAgent::Authorization). Kept beside params rather than
|
|
115
|
+
# in them: params reach templates and are part of what a generation
|
|
116
|
+
# is *about*, while the actor is who it is *for*, and only the caller
|
|
117
|
+
# may say.
|
|
118
|
+
def initialize(agent, params, actor: nil)
|
|
114
119
|
@agent = agent
|
|
115
120
|
@params = params
|
|
121
|
+
@actor = actor
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Adds parameters, keeping the actor. Lets +as+ and +with+ chain in
|
|
125
|
+
# either order.
|
|
126
|
+
# @return [ActiveAgent::Parameterized::Agent]
|
|
127
|
+
def with(params = {})
|
|
128
|
+
self.class.new(@agent, @params.merge(params), actor: @actor)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Runs on behalf of +actor+, keeping any parameters.
|
|
132
|
+
# @return [ActiveAgent::Parameterized::Agent]
|
|
133
|
+
def as(actor)
|
|
134
|
+
self.class.new(@agent, @params, actor: actor)
|
|
116
135
|
end
|
|
117
136
|
|
|
118
137
|
# Intercepts calls to agent action methods and creates parameterized generations.
|
|
@@ -124,7 +143,9 @@ module ActiveAgent
|
|
|
124
143
|
# @raise [NoMethodError] if the method doesn't exist on the agent class
|
|
125
144
|
def method_missing(method_name, ...)
|
|
126
145
|
if @agent.public_instance_methods.include?(method_name)
|
|
127
|
-
ActiveAgent::Parameterized::Generation.new(@agent, method_name, @params, ...)
|
|
146
|
+
ActiveAgent::Parameterized::Generation.new(@agent, method_name, @params, ...).tap do |generation|
|
|
147
|
+
generation.actor = @actor
|
|
148
|
+
end
|
|
128
149
|
else
|
|
129
150
|
super
|
|
130
151
|
end
|
|
@@ -146,6 +167,17 @@ module ActiveAgent
|
|
|
146
167
|
#
|
|
147
168
|
# @api private
|
|
148
169
|
class Generation < ActiveAgent::Generation
|
|
170
|
+
# @param agent_class [Class] the agent class
|
|
171
|
+
# @param action [Symbol, String] the action method name
|
|
172
|
+
# @param params [Hash] the parameters to set on the agent instance
|
|
173
|
+
# @param args [Array] additional arguments for the action method
|
|
174
|
+
# The caller the generation runs on behalf of. Assigned after
|
|
175
|
+
# construction rather than taken as a keyword, because the action's own
|
|
176
|
+
# arguments are forwarded here and an actor keyword would collide with
|
|
177
|
+
# one of the same name.
|
|
178
|
+
# @return [Object, nil]
|
|
179
|
+
attr_accessor :actor
|
|
180
|
+
|
|
149
181
|
# @param agent_class [Class] the agent class
|
|
150
182
|
# @param action [Symbol, String] the action method name
|
|
151
183
|
# @param params [Hash] the parameters to set on the agent instance
|
|
@@ -163,6 +195,7 @@ module ActiveAgent
|
|
|
163
195
|
def agent
|
|
164
196
|
@agent ||= agent_class.new.tap do |agent|
|
|
165
197
|
agent.params = @params
|
|
198
|
+
agent.current_user = @actor
|
|
166
199
|
agent.process(action_name, *args, **kwargs)
|
|
167
200
|
end
|
|
168
201
|
end
|
|
@@ -180,8 +213,12 @@ module ActiveAgent
|
|
|
180
213
|
if processed?
|
|
181
214
|
super
|
|
182
215
|
else
|
|
216
|
+
# The actor rides as an ordinary job argument, which ActiveJob
|
|
217
|
+
# serializes through GlobalID like any other record, so a worker on
|
|
218
|
+
# another machine authorizes as the same caller.
|
|
183
219
|
agent_class.generation_job.set(job_options).perform_later(
|
|
184
|
-
agent_class.name, action_name.to_s, generation_method.to_s,
|
|
220
|
+
agent_class.name, action_name.to_s, generation_method.to_s,
|
|
221
|
+
params: @params, args: args, kwargs: kwargs, actor: @actor
|
|
185
222
|
)
|
|
186
223
|
end
|
|
187
224
|
end
|
|
@@ -16,7 +16,9 @@ module ActiveAgent
|
|
|
16
16
|
# @return [Proc] callback proc that accepts (action_name, *args, **kwargs)
|
|
17
17
|
def tools_function
|
|
18
18
|
proc do |action_name, *args, **kwargs|
|
|
19
|
-
|
|
19
|
+
# Marked as a tool call so a refusal is reported to the model as a
|
|
20
|
+
# result rather than raised through the run (see Authorization).
|
|
21
|
+
with_tool_call { process(action_name, *args, **kwargs) }
|
|
20
22
|
end
|
|
21
23
|
end
|
|
22
24
|
end
|
|
@@ -117,6 +117,7 @@ module ActiveAgent
|
|
|
117
117
|
def generate(arguments)
|
|
118
118
|
agent = definition.resolved_agent_class.new
|
|
119
119
|
agent.params = resolved_params(arguments)
|
|
120
|
+
inherit_actor(agent)
|
|
120
121
|
agent.process(definition.action, **arguments)
|
|
121
122
|
|
|
122
123
|
definition.backend.apply(agent)
|
|
@@ -126,6 +127,22 @@ module ActiveAgent
|
|
|
126
127
|
agent.process_prompt
|
|
127
128
|
end
|
|
128
129
|
|
|
130
|
+
# A delegated generation runs on behalf of whoever the parent runs for.
|
|
131
|
+
# The sub-agent gets the parent's caller before its action runs, so its
|
|
132
|
+
# own before_action callbacks and any scope its tools read through decide
|
|
133
|
+
# against the same person — a parent authorized as one user must not
|
|
134
|
+
# hand its specialists an unattributed run, which a correctly written
|
|
135
|
+
# host scope reads as "no access". Hosts on an older framework, where an
|
|
136
|
+
# agent has no caller to carry, are left as they were.
|
|
137
|
+
#
|
|
138
|
+
# @param agent [ActiveAgent::Base]
|
|
139
|
+
# @return [void]
|
|
140
|
+
def inherit_actor(agent)
|
|
141
|
+
return unless owner.respond_to?(:current_user) && agent.respond_to?(:current_user=)
|
|
142
|
+
|
|
143
|
+
agent.current_user = owner.current_user
|
|
144
|
+
end
|
|
145
|
+
|
|
129
146
|
# A delegated generation is part of its parent's work, so it carries the
|
|
130
147
|
# parent's trace id — otherwise the sub-agent's tokens and latency land
|
|
131
148
|
# in a separate trace and the budget you set has nothing to show for it.
|
|
@@ -12,6 +12,7 @@ module ActiveAgent
|
|
|
12
12
|
# tool_error — a tool the agent called returned an error
|
|
13
13
|
# missing_capability — the agent said no tool covers the task
|
|
14
14
|
# expected_tool_not_called — the scenario expects a tool the agent did not call
|
|
15
|
+
# ungrounded_answer — the answer states specifics no tool call supplied
|
|
15
16
|
# forbidden_content — the answer contains a pattern the scenario forbids
|
|
16
17
|
# missing_content — the answer lacks a pattern the scenario expects
|
|
17
18
|
# low_quality — the answer scored below the threshold
|
|
@@ -21,7 +22,7 @@ module ActiveAgent
|
|
|
21
22
|
# Returns nil for a passing result.
|
|
22
23
|
class Diagnosis
|
|
23
24
|
FAULTS = %w[
|
|
24
|
-
run_error tool_error missing_capability expected_tool_not_called
|
|
25
|
+
run_error tool_error missing_capability expected_tool_not_called ungrounded_answer
|
|
25
26
|
forbidden_content missing_content low_quality judge_unavailable
|
|
26
27
|
].freeze
|
|
27
28
|
|
|
@@ -38,6 +39,19 @@ module ActiveAgent
|
|
|
38
39
|
/\bcan(?:'|no)t (?:be )?(?:done|determined|answered) with (?:the|my) (?:current|available) tools\b/i
|
|
39
40
|
].freeze
|
|
40
41
|
|
|
42
|
+
# Phrasings that state a specific fact — a record id, a date, a count of
|
|
43
|
+
# things — which an agent that called no tool can only have invented.
|
|
44
|
+
# Deliberately narrow: a number inside prose ("here are three options",
|
|
45
|
+
# "within 30 days") is not a claim about data, and a false positive here
|
|
46
|
+
# fails a scenario that may have passed on its merits.
|
|
47
|
+
SPECIFIC_CLAIMS = [
|
|
48
|
+
/#\d+\b/,
|
|
49
|
+
/\b\d{4}-\d{2}-\d{2}\b/,
|
|
50
|
+
/\b(?:you have|there are|there is|we have|I found|found|showing|a total of)\s+(?:\*\*)?\d+\b/i,
|
|
51
|
+
/\b\d+\s+(?:\*\*)?(?:open|overdue|pending|active|closed|resolved|completed|unpaid|outstanding|new|matching|
|
|
52
|
+
records?|results?|rows?|entries|items?|tickets?|orders?|tasks?|issues?|invoices?|customers?|users?|milestones?)\b/ix
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
41
55
|
Result = Struct.new(:fault, :summary, :recommendation, :evidence, keyword_init: true) do
|
|
42
56
|
def to_h
|
|
43
57
|
{
|
|
@@ -76,7 +90,7 @@ module ActiveAgent
|
|
|
76
90
|
end
|
|
77
91
|
|
|
78
92
|
def call
|
|
79
|
-
run_error || tool_error || missing_capability || expected_tool_not_called ||
|
|
93
|
+
run_error || tool_error || missing_capability || expected_tool_not_called || ungrounded_answer ||
|
|
80
94
|
forbidden_content || missing_content || low_quality
|
|
81
95
|
end
|
|
82
96
|
|
|
@@ -178,16 +192,58 @@ module ActiveAgent
|
|
|
178
192
|
"#{agent} answered with #{called_tools.uniq.join(', ')} instead of #{expected.join(', ')}. Sharpen " \
|
|
179
193
|
"both tools' descriptions so the model can tell them apart, or say in the instructions which tool " \
|
|
180
194
|
"answers this kind of task."
|
|
195
|
+
elsif asserts_specifics?
|
|
196
|
+
"#{expected.join(', ')} is available but #{agent.downcase} answered without calling any tool and " \
|
|
197
|
+
"stated specifics it could not have looked up (\"#{claim_excerpt}\"). Treat the answer as invented: " \
|
|
198
|
+
"instruct it to answer this kind of task only from a tool result, and to say so when it has none."
|
|
181
199
|
else
|
|
182
200
|
"#{expected.join(', ')} is available but #{agent.downcase} answered without calling any tool. Tell " \
|
|
183
201
|
"it in the instructions to prefer tool-backed answers for this kind of task, and check the tool's " \
|
|
184
202
|
"description says what it returns."
|
|
185
203
|
end
|
|
186
204
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
205
|
+
summary = "Expected #{expected.join(' or ')} to be called; #{agent.downcase} called " \
|
|
206
|
+
"#{called_tools.uniq.presence&.join(', ') || 'nothing'}"
|
|
207
|
+
summary += " and answered with specifics no tool supplied" if called_tools.empty? && asserts_specifics?
|
|
208
|
+
|
|
209
|
+
result("expected_tool_not_called", "#{summary}.", recommendation,
|
|
210
|
+
"expected" => expected, "called" => called_tools, "unavailable" => unavailable,
|
|
211
|
+
"ungrounded" => (called_tools.empty? && asserts_specifics?) || nil, "claim" => (claim_excerpt if called_tools.empty?))
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The answer states specifics — a count, an id, a date — that no tool
|
|
215
|
+
# call could have supplied. Reached only when the scenario names no
|
|
216
|
+
# expected tool (expected_tool_not_called reports the same fabrication
|
|
217
|
+
# otherwise) and only for an agent that had tools to call: one with
|
|
218
|
+
# none answers from its instructions by design, and whether that is
|
|
219
|
+
# acceptable is the judge's call, not a mechanical one.
|
|
220
|
+
def ungrounded_answer
|
|
221
|
+
return nil if @available_tools.empty? || called_tools.any?
|
|
222
|
+
return nil unless asserts_specifics?
|
|
223
|
+
|
|
224
|
+
result("ungrounded_answer",
|
|
225
|
+
"#{agent} stated specifics (\"#{claim_excerpt}\") without calling any tool that could have supplied them.",
|
|
226
|
+
"Nothing in the answer came from a tool, so the figures in it are invented. Tell #{agent.downcase} in its " \
|
|
227
|
+
"instructions to answer this kind of task only from a tool result and to say when it has none; if none of " \
|
|
228
|
+
"#{@available_tools.join(', ')} returns this data, add a tool that does.",
|
|
229
|
+
"claim" => claim_excerpt, "tools_available" => @available_tools)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def asserts_specifics?
|
|
233
|
+
specific_claim.present?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def specific_claim
|
|
237
|
+
return @specific_claim if defined?(@specific_claim)
|
|
238
|
+
|
|
239
|
+
@specific_claim = SPECIFIC_CLAIMS.lazy.filter_map { |pattern| answer.match(pattern) }.first
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def claim_excerpt
|
|
243
|
+
match = specific_claim
|
|
244
|
+
return nil unless match
|
|
245
|
+
|
|
246
|
+
answer[[ match.begin(0) - 40, 0 ].max, 120].to_s.strip
|
|
191
247
|
end
|
|
192
248
|
|
|
193
249
|
def forbidden_content
|
|
@@ -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,22 +46,38 @@ 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
|
-
#
|
|
53
|
-
#
|
|
54
|
-
# re-run
|
|
55
|
-
#
|
|
56
|
-
#
|
|
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.
|
|
57
61
|
#
|
|
58
|
-
# @return [
|
|
59
|
-
def self.
|
|
60
|
-
|
|
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
|
|
61
70
|
|
|
62
|
-
|
|
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)
|
|
63
79
|
end
|
|
64
|
-
private_class_method :
|
|
80
|
+
private_class_method :from_value
|
|
65
81
|
|
|
66
82
|
# The provider a bare model name runs under. A rule whose provider the
|
|
67
83
|
# caller does not offer is skipped, so an app without Ollama does not
|
|
@@ -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
|
|
@@ -168,6 +168,71 @@ module ActiveAgent
|
|
|
168
168
|
@scope = block
|
|
169
169
|
end
|
|
170
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
|
+
|
|
171
236
|
# The full, fixed tool roster in provider function-calling format.
|
|
172
237
|
#
|
|
173
238
|
# @return [Array<Hash>] tool definitions with :name, :description, :parameters
|
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"
|
|
@@ -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.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Generators
|
|
5
|
+
# Writes a starter ActiveAgent::SchemaTools class for one model:
|
|
6
|
+
#
|
|
7
|
+
# bin/rails generate active_agent:schema_tools Reservation
|
|
8
|
+
#
|
|
9
|
+
# The class it writes exposes nothing beyond +id+ until someone uncomments
|
|
10
|
+
# a column, because which columns an agent may filter on and read back is
|
|
11
|
+
# a judgement about exposure, not a fact about the table (#440). Every
|
|
12
|
+
# column the model has is listed, commented out, minus the ones that look
|
|
13
|
+
# like secrets, so the allowlist is a review step rather than a blank page.
|
|
14
|
+
class SchemaToolsGenerator < ::Rails::Generators::NamedBase
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
|
|
17
|
+
# Columns never suggested, whatever the model: reading one back would
|
|
18
|
+
# hand a model a credential, and filtering on one leaks it a character
|
|
19
|
+
# at a time through the row counts.
|
|
20
|
+
SECRET_COLUMNS = /password|digest|token|secret|api_key|otp|encrypted|ssn/i
|
|
21
|
+
|
|
22
|
+
class_option :policy, type: :boolean, default: nil,
|
|
23
|
+
desc: "Scope every read through <Model>Policy::Scope (default: when that policy exists)"
|
|
24
|
+
|
|
25
|
+
check_class_collision suffix: "Tools"
|
|
26
|
+
|
|
27
|
+
def create_tools_file
|
|
28
|
+
template "schema_tools.rb.tt", File.join("app/agent_tools", class_path, "#{file_name}_tools.rb")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# "Reservation" and "ReservationTools" both name the Reservation model.
|
|
34
|
+
def file_name # :doc:
|
|
35
|
+
@_file_name ||= super.sub(/_tools\z/i, "")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def model_class
|
|
39
|
+
@model_class ||= class_name.safe_constantize
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def policy_class_name
|
|
43
|
+
"#{class_name}Policy"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def policy?
|
|
47
|
+
return options[:policy] unless options[:policy].nil?
|
|
48
|
+
|
|
49
|
+
"#{policy_class_name}::Scope".safe_constantize.present?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# [name, type] for every column the model has, or [] when the model or
|
|
53
|
+
# its table cannot be read from here (a model that does not exist yet,
|
|
54
|
+
# or a database that is not set up) — the file is still written.
|
|
55
|
+
def columns
|
|
56
|
+
@columns ||= begin
|
|
57
|
+
if model_class.respond_to?(:columns) && model_class.respond_to?(:table_exists?) && model_class.table_exists?
|
|
58
|
+
model_class.columns.map { |column| [ column.name, column.type ] }
|
|
59
|
+
else
|
|
60
|
+
[]
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError
|
|
63
|
+
[]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def suggested_columns
|
|
68
|
+
columns.reject { |name, _type| name == "id" || name.match?(SECRET_COLUMNS) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def secret_columns
|
|
72
|
+
columns.select { |name, _type| name.match?(SECRET_COLUMNS) }.map(&:first)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def collection_name
|
|
76
|
+
class_name.demodulize.underscore.pluralize
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def resource_name
|
|
80
|
+
class_name.demodulize.underscore
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<% module_namespacing do -%>
|
|
2
|
+
# Bounded, read-only agent tools over <%= class_name %>:
|
|
3
|
+
# find_<%= collection_name %>, count_<%= collection_name %>, get_<%= resource_name %>.
|
|
4
|
+
#
|
|
5
|
+
# Nothing is exposed until you say so. Move a column from the commented lists
|
|
6
|
+
# into `filterable` (an agent may filter on it) or `returns` (an agent may
|
|
7
|
+
# read it back). Each one is a judgement about exposure, not a fact about the
|
|
8
|
+
# table: leave out anything a model should never see.
|
|
9
|
+
class <%= class_name %>Tools < ActiveAgent::SchemaTools
|
|
10
|
+
model <%= class_name %>
|
|
11
|
+
|
|
12
|
+
filterable :id
|
|
13
|
+
<% if suggested_columns.any? -%>
|
|
14
|
+
# filterable <%= suggested_columns.map { |name, _type| ":#{name}" }.join(", ") %>
|
|
15
|
+
<% else -%>
|
|
16
|
+
# filterable :status, :owner_id # columns an agent may filter on
|
|
17
|
+
<% end -%>
|
|
18
|
+
|
|
19
|
+
returns :id
|
|
20
|
+
<% if suggested_columns.any? -%>
|
|
21
|
+
# returns <%= suggested_columns.map { |name, _type| ":#{name}" }.join(", ") %>
|
|
22
|
+
<% else -%>
|
|
23
|
+
# returns :id, :title, :status # columns an agent may read back
|
|
24
|
+
<% end -%>
|
|
25
|
+
<% if suggested_columns.any? -%>
|
|
26
|
+
#
|
|
27
|
+
# Columns and their types:
|
|
28
|
+
<% suggested_columns.each do |name, type| -%>
|
|
29
|
+
# <%= name.ljust(24) %> <%= type %>
|
|
30
|
+
<% end -%>
|
|
31
|
+
<% end -%>
|
|
32
|
+
<% if secret_columns.any? -%>
|
|
33
|
+
#
|
|
34
|
+
# Not suggested, and not to be added: <%= secret_columns.join(", ") %>.
|
|
35
|
+
<% end -%>
|
|
36
|
+
|
|
37
|
+
<% if policy? -%>
|
|
38
|
+
# Every read runs through <%= policy_class_name %>::Scope for the acting
|
|
39
|
+
# user, so an agent sees exactly what that user could see.
|
|
40
|
+
scope_by_policy
|
|
41
|
+
<% else -%>
|
|
42
|
+
# Every read should run through a relation scoped to the acting user. Without
|
|
43
|
+
# a scope the tools read the whole table. Return <%= class_name %>.none for an
|
|
44
|
+
# actor with no access; never widen for a nil actor.
|
|
45
|
+
# scope { |actor| actor ? <%= class_name %>.where(owner: actor) : <%= class_name %>.none }
|
|
46
|
+
<% end -%>
|
|
47
|
+
end
|
|
48
|
+
<% end -%>
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activeagent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Justin Bowen
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: actionpack
|
|
@@ -410,6 +409,7 @@ files:
|
|
|
410
409
|
- lib/active_agent.rb
|
|
411
410
|
- lib/active_agent/base.rb
|
|
412
411
|
- lib/active_agent/collector.rb
|
|
412
|
+
- lib/active_agent/concerns/authorization.rb
|
|
413
413
|
- lib/active_agent/concerns/callbacks.rb
|
|
414
414
|
- lib/active_agent/concerns/delegation.rb
|
|
415
415
|
- lib/active_agent/concerns/observers.rb
|
|
@@ -578,6 +578,9 @@ files:
|
|
|
578
578
|
- lib/generators/active_agent/agent/agent_generator.rb
|
|
579
579
|
- lib/generators/active_agent/install/USAGE
|
|
580
580
|
- lib/generators/active_agent/install/install_generator.rb
|
|
581
|
+
- lib/generators/active_agent/schema_tools/USAGE
|
|
582
|
+
- lib/generators/active_agent/schema_tools/schema_tools_generator.rb
|
|
583
|
+
- lib/generators/active_agent/schema_tools/templates/schema_tools.rb.tt
|
|
581
584
|
- lib/generators/active_agent/templates/active_agent.yml
|
|
582
585
|
- lib/generators/active_agent/templates/agent.rb.tt
|
|
583
586
|
- lib/generators/active_agent/templates/application_agent.rb.tt
|
|
@@ -602,7 +605,6 @@ metadata:
|
|
|
602
605
|
documentation_uri: https://docs.activeagents.ai
|
|
603
606
|
source_code_uri: https://github.com/activeagents/activeagent
|
|
604
607
|
rubygems_mfa_required: 'true'
|
|
605
|
-
post_install_message:
|
|
606
608
|
rdoc_options: []
|
|
607
609
|
require_paths:
|
|
608
610
|
- lib
|
|
@@ -617,8 +619,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
617
619
|
- !ruby/object:Gem::Version
|
|
618
620
|
version: '0'
|
|
619
621
|
requirements: []
|
|
620
|
-
rubygems_version: 3.
|
|
621
|
-
signing_key:
|
|
622
|
+
rubygems_version: 3.6.9
|
|
622
623
|
specification_version: 4
|
|
623
624
|
summary: Rails AI Agents Framework
|
|
624
625
|
test_files: []
|