actionagent 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.
@@ -19,12 +19,15 @@ module ActionAgent
19
19
  # Conversations returned to the runner's picker when no limit is asked for.
20
20
  CONVERSATIONS_LIMIT = 50
21
21
  # Keywords Agent#execute takes in its own right, which per-run overrides
22
- # must never supply (see #execution_params).
23
- RESERVED_EXECUTION_KEYS = [ :attachments, :action ].freeze
22
+ # must never supply (see #execution_params). `actor` is here for the
23
+ # same reason as the rest and one more: a keyword splat wins over the
24
+ # arguments before it, so a client sending params[params][actor] would
25
+ # otherwise name the caller its own run is authorized as.
26
+ RESERVED_EXECUTION_KEYS = [ :attachments, :action, :actor, :current_user ].freeze
24
27
 
25
28
  before_action :set_agent, only: [
26
29
  :show, :update, :destroy, :versions, :runs, :execute, :test, :restore, :duplicate, :export, :analytics,
27
- :conversations, :create_conversation
30
+ :tool_roster, :conversations, :create_conversation
28
31
  ]
29
32
  before_action :require_execution_enabled!, only: [ :execute, :test ]
30
33
  before_action :require_owner!, only: [ :execute, :test ]
@@ -170,6 +173,7 @@ module ActionAgent
170
173
  execution_prompt,
171
174
  action: params[:action_name],
172
175
  attachments: uploaded_attachments,
176
+ actor: agent_actor,
173
177
  **execution_params
174
178
  )
175
179
  record_execution_usage
@@ -183,6 +187,7 @@ module ActionAgent
183
187
  execution_prompt,
184
188
  action: params[:action_name],
185
189
  attachments: uploaded_attachments,
190
+ actor: agent_actor,
186
191
  **execution_params
187
192
  )
188
193
  record_execution_usage
@@ -256,6 +261,19 @@ module ActionAgent
256
261
  }
257
262
  end
258
263
 
264
+ # GET /api/agents/:id/tool_roster
265
+ #
266
+ # What the Tools tab edits: the MCP services this agent can be given
267
+ # and the tools it can be offered, each with the calls, errors and
268
+ # latency recorded for it in the window.
269
+ def tool_roster
270
+ render json: AgentToolRoster.new(
271
+ agent: @agent,
272
+ traces: owned_traces,
273
+ hours: params.fetch(:hours, ToolDiscovery::DEFAULT_WINDOW_HOURS).to_i
274
+ ).as_json
275
+ end
276
+
259
277
  # GET /api/agents/:id/analytics
260
278
  #
261
279
  # Every execution of this agent, whoever ran it — the same merged model
@@ -478,17 +496,33 @@ module ActionAgent
478
496
  end
479
497
 
480
498
  def agent_params
481
- params.require(:agent).permit(
499
+ permitted = params.require(:agent).permit(
482
500
  :name, :description, :provider, :model, :instructions,
483
501
  :preset_type, :agent_class_name, :status,
484
502
  appearance: {},
485
503
  action_prompts: [ :name, :prompt, :expose_as_tool ],
486
504
  instruction_sets: [],
487
505
  tools: [],
488
- mcp_servers: [],
489
506
  model_config: {},
490
507
  response_format: {}
491
508
  )
509
+ permitted[:mcp_servers] = mcp_server_params if params[:agent].key?(:mcp_servers)
510
+ permitted
511
+ end
512
+
513
+ # An agent names its MCP servers either as bare strings or as hashes —
514
+ # the Tools tab writes { key, name, tools } so a service can be enabled
515
+ # with only some of what it serves. Both shapes are permitted, because
516
+ # every agent saved before the tab existed carries the first one and a
517
+ # round-trip through the editor must not rewrite it.
518
+ def mcp_server_params
519
+ Array(params[:agent][:mcp_servers]).map do |entry|
520
+ if entry.respond_to?(:permit)
521
+ entry.permit(:key, :name, :url, :command, :transport, tools: []).to_h
522
+ else
523
+ entry.to_s
524
+ end
525
+ end
492
526
  end
493
527
 
494
528
  def version_json(version, include_diff: false)
@@ -68,6 +68,23 @@ module ActionAgent
68
68
  ActionAgent.trace_model.for_account(current_account)
69
69
  end
70
70
 
71
+ # The caller an agent run executes on behalf of.
72
+ #
73
+ # The host's seam first (ActionAgent.agent_actor_resolver), then the
74
+ # signed-in user. Never the tenant: an account is who is billed, not
75
+ # who is allowed, and handing a Pundit policy an account would either
76
+ # raise or quietly authorize as the whole workspace.
77
+ def agent_actor
78
+ return @agent_actor if defined?(@agent_actor)
79
+
80
+ @agent_actor =
81
+ if (resolver = ActionAgent.agent_actor_resolver)
82
+ resolver.arity.zero? ? resolver.call : resolver.call(self)
83
+ else
84
+ current_user
85
+ end
86
+ end
87
+
71
88
  # The tenant, when the host app has one. current_owner already
72
89
  # resolves it in multi-tenant mode; single-tenant installs have none.
73
90
  def current_account
@@ -7,7 +7,11 @@ module ActionAgent
7
7
  # themselves as Resource Agents backed by their ActiveRecord state:
8
8
  #
9
9
  # - tools/list & tools/call: each agent is a callable tool (run_<slug>)
10
- # that executes a synchronous generation run.
10
+ # that executes a synchronous generation run, and each of the host's
11
+ # schema tools (ActiveAgent::SchemaTools, the classes the dashboard
12
+ # discovers) is callable directly — find_<records>, count_<records>,
13
+ # get_<record> — as this key's caller, so a client reads the host's
14
+ # records under the same scope an agent run would (#439).
11
15
  # - resources/list & resources/read: each agent is an agent://<slug>
12
16
  # resource whose content is its live scorecard (config + stats + memory
13
17
  # summary from the solid_agent datasets).
@@ -28,6 +32,10 @@ module ActionAgent
28
32
  JSONRPC_METHOD_NOT_FOUND = -32601
29
33
  JSONRPC_INVALID_PARAMS = -32602
30
34
  JSONRPC_SERVER_ERROR = -32000
35
+ # No JSON-RPC code means "forbidden", and the MCP spec leaves -32000..
36
+ # -32099 to the server. A refusal gets its own so a client can tell it
37
+ # from a run that merely failed.
38
+ JSONRPC_FORBIDDEN = -32003
31
39
 
32
40
  # POST /mcp
33
41
  def create
@@ -102,12 +110,46 @@ module ActionAgent
102
110
  ActionAgent.agents_for(@owner).where.not(status: :archived).order(:slug)
103
111
  end
104
112
 
113
+ # The caller an MCP-invoked run executes on behalf of.
114
+ #
115
+ # The key's owner is the identity that authenticated this request, so
116
+ # it is the default; a host issuing keys per end user overrides it
117
+ # with ActionAgent.agent_actor_resolver, which is handed this
118
+ # controller and can read the request however it likes.
119
+ #
120
+ # The agent's own callbacks decide what the actor may do — that is the
121
+ # point of carrying one. This only answers *who*.
122
+ def agent_actor
123
+ return @agent_actor if defined?(@agent_actor)
124
+
125
+ @agent_actor =
126
+ if (resolver = ActionAgent.agent_actor_resolver)
127
+ resolver.arity.zero? ? resolver.call : resolver.call(self)
128
+ else
129
+ @api_key.respond_to?(:user) && @api_key.user ? @api_key.user : @owner
130
+ end
131
+ end
132
+
133
+ # Whether the run ended because the agent refused this caller, rather
134
+ # than because something broke. Matched on the error class the
135
+ # framework raises (and the ones a host names with `denies_with`), not
136
+ # on the message.
137
+ def run_refused?(run)
138
+ klass = run.output_metadata.is_a?(Hash) ? run.output_metadata["error_class"] : nil
139
+ return false if klass.blank?
140
+
141
+ klass.to_s == "ActiveAgent::NotAuthorized" ||
142
+ ActiveAgent::Base.authorization_errors.any? { |error| error.name == klass.to_s }
143
+ end
144
+
105
145
  def initialize_result
106
146
  {
107
147
  protocolVersion: PROTOCOL_VERSION,
108
148
  capabilities: { tools: {}, resources: {} },
109
149
  serverInfo: { name: "activeagents", version: "1.0" },
110
- instructions: "Each tool runs one of this account's agents. Each agent://<slug> resource returns the agent's live scorecard."
150
+ instructions: "Each run_<slug> tool runs one of this account's agents; every other tool reads the host " \
151
+ "application's records directly, as the caller this key authenticates. Each agent://<slug> " \
152
+ "resource returns the agent's live scorecard."
111
153
  }
112
154
  end
113
155
 
@@ -138,11 +180,32 @@ module ActionAgent
138
180
  agent_tools
139
181
  end
140
182
 
141
- { tools: tools }
183
+ { tools: tools + schema_tools_list }
184
+ end
185
+
186
+ # The host's schema tools, offered as the same tool definitions an
187
+ # agent run receives — the parameter schema is the tool's own, so a
188
+ # client sees which columns it may filter on. Every generated tool of
189
+ # every discovered class is listed; the host chose what to declare, and
190
+ # ActionAgent.mcp_schema_tools switches the whole set off.
191
+ def schema_tools_list
192
+ return [] unless ActionAgent.mcp_schema_tools?
193
+
194
+ ActionAgent.schema_tool_classes.flat_map do |klass|
195
+ klass.tool_definitions.map do |definition|
196
+ {
197
+ name: definition[:name],
198
+ description: definition[:description],
199
+ inputSchema: definition[:parameters] || definition[:input_schema] || { type: "object", properties: {} }
200
+ }
201
+ end
202
+ end
142
203
  end
143
204
 
144
205
  def tools_call
145
206
  name = params.dig(:params, :name).to_s
207
+ return schema_tool_call(name) if ActionAgent.mcp_schema_tools? && ActionAgent.schema_tool_class_for(name)
208
+
146
209
  slug, action = name.delete_prefix("run_").split("__", 2)
147
210
  agent = key_agents.find_by(slug: slug)
148
211
  raise McpError.new("Unknown tool: #{name}", JSONRPC_INVALID_PARAMS) unless agent
@@ -160,10 +223,16 @@ module ActionAgent
160
223
  raise McpError.new(denial.is_a?(Hash) ? denial[:message] || denial["message"] : denial)
161
224
  end
162
225
 
163
- run = agent.test_execute(message, action: action)
226
+ run = agent.test_execute(message, action: action, actor: agent_actor)
164
227
  ActionAgent.record_usage(@owner, :execution)
165
228
 
166
229
  if run.failed?
230
+ # A refusal is not a result. An agent that declined on this
231
+ # caller's behalf answers as a JSON-RPC error, so the client sees
232
+ # "not allowed" rather than an empty, confident answer — the
233
+ # failure mode a nil-actor scope produces on its own.
234
+ raise McpError.new(run.error_message.to_s, JSONRPC_FORBIDDEN) if run_refused?(run)
235
+
167
236
  { content: [ { type: "text", text: "Agent run failed: #{run.error_message}" } ], isError: true }
168
237
  else
169
238
  {
@@ -179,6 +248,48 @@ module ActionAgent
179
248
  end
180
249
  end
181
250
 
251
+ # Calls a schema tool directly, as this key's caller. No generation runs,
252
+ # so neither the execution switch nor the execution quota applies: this
253
+ # is a read of the host's records through the host's own scope.
254
+ #
255
+ # A boundary violation — an undeclared filter, an id the caller cannot
256
+ # see — comes back as a tool result with isError, the shape an agent
257
+ # run would hand its model, so a client can correct its call. A refusal
258
+ # raised by the host's scope (an authorization gem's error, or
259
+ # ActiveAgent::NotAuthorized) answers as a JSON-RPC error, as an
260
+ # agent's refusal does.
261
+ def schema_tool_call(name)
262
+ klass = ActionAgent.schema_tool_class_for(name)
263
+ result = call_schema_tool(klass, name)
264
+ response = { content: [ { type: "text", text: result.to_json } ], structuredContent: result }
265
+ response[:isError] = true if result.respond_to?(:key?) && (result.key?(:error) || result.key?("error"))
266
+ response
267
+ end
268
+
269
+ def call_schema_tool(klass, name)
270
+ klass.call(name, actor: agent_actor, **schema_tool_arguments)
271
+ rescue StandardError => e
272
+ raise McpError.new(e.message, JSONRPC_FORBIDDEN) if authorization_error?(e)
273
+
274
+ raise
275
+ end
276
+
277
+ # The framework's refusal (the default in Base.authorization_errors), or
278
+ # one of the errors a host named with `denies_with` — matched on the
279
+ # class, as run_refused? matches a run's.
280
+ def authorization_error?(error)
281
+ ActiveAgent::Base.authorization_errors.any? { |klass| error.is_a?(klass) }
282
+ end
283
+
284
+ # The call's arguments as keywords, minus any that name the caller:
285
+ # the actor is the key's identity, never something a client sends
286
+ # (AgentExecutionService::ACTOR_KEYWORDS, for the same reason).
287
+ def schema_tool_arguments
288
+ arguments = params.dig(:params, :arguments)
289
+ arguments = arguments.respond_to?(:to_unsafe_h) ? arguments.to_unsafe_h : arguments.to_h
290
+ arguments.to_h.symbolize_keys.except(*AgentExecutionService::ACTOR_KEYWORDS)
291
+ end
292
+
182
293
  def resources_list
183
294
  {
184
295
  resources: key_agents.map do |agent|
@@ -38,7 +38,10 @@ module ActionAgent
38
38
  status: :failed,
39
39
  completed_at: Time.current,
40
40
  error_message: e.message,
41
- error_backtrace: e.backtrace&.first(10)&.join("\n")
41
+ error_backtrace: e.backtrace&.first(10)&.join("\n"),
42
+ # Same as the synchronous path: the class is what distinguishes
43
+ # a refusal from a crash.
44
+ output_metadata: run.output_metadata.to_h.merge("error_class" => e.class.name)
42
45
  )
43
46
  run.add_log("Execution failed: #{e.message}", level: :error)
44
47
  end
@@ -81,6 +81,25 @@ module ActionAgent
81
81
  terminal playwright filesystem code database slack fetch search edit translate memory agents ui
82
82
  ].freeze
83
83
 
84
+ # One line per capability, for the roster rows that offer them. A name
85
+ # alone ("ui", "agents") doesn't say what enabling it gives the model,
86
+ # and the Tools tab is where that question gets asked.
87
+ TOOL_DESCRIPTIONS = {
88
+ "terminal" => "Runs a shell command in the workspace sandbox.",
89
+ "playwright" => "Drives a headless browser: navigate, click, read the page.",
90
+ "filesystem" => "Reads and writes files under an allow-listed set of directories.",
91
+ "code" => "Reads and edits files in the connected repository.",
92
+ "database" => "Runs read-only SQL against the app database.",
93
+ "slack" => "Reads channels and posts messages as the workspace bot.",
94
+ "fetch" => "Fetches a URL and converts the page to markdown for the model to read.",
95
+ "search" => "Web search through the workspace provider.",
96
+ "edit" => "Applies a structured edit to a document.",
97
+ "translate" => "Translates text through the translation agent.",
98
+ "memory" => "Reads and writes durable notes across runs of this agent.",
99
+ "agents" => "Delegates a task to another agent in this workspace.",
100
+ "ui" => "Renders a form or table back into the chat surface."
101
+ }.freeze
102
+
84
103
  # Every tool an agent may enable: the built-ins plus each tool generated by
85
104
  # the host's declared ActiveAgent::SchemaTools classes (ActionAgent.schema_tools).
86
105
  #
@@ -254,9 +273,12 @@ module ActionAgent
254
273
  # before the job is enqueued, so a worker on another machine finds them
255
274
  # attached. +params+ (provider/model overrides, the context_id of a
256
275
  # conversation to continue) are kept on the run as input_params.
257
- def execute(input_prompt, action: nil, attachments: [], **params)
276
+ def execute(input_prompt, action: nil, attachments: [], actor: nil, **params)
258
277
  ensure_executable!
259
- run = create_run(input_prompt, action: action, attachments: attachments, params: params, status: :pending)
278
+ run = create_run(
279
+ input_prompt, action: action, attachments: attachments, params: params,
280
+ actor: actor, status: :pending
281
+ )
260
282
 
261
283
  # Queue the execution job
262
284
  AgentExecutionJob.perform_later(run.id)
@@ -265,12 +287,13 @@ module ActionAgent
265
287
  end
266
288
 
267
289
  # Quick test execution (synchronous)
268
- def test_execute(input_prompt, action: nil, attachments: [], **params)
290
+ def test_execute(input_prompt, action: nil, attachments: [], actor: nil, **params)
269
291
  ensure_executable!
270
292
  run = create_run(
271
293
  input_prompt, action: action, attachments: attachments, params: params,
272
- status: :running, started_at: Time.current
294
+ actor: actor, status: :running, started_at: Time.current
273
295
  )
296
+ run.actor = actor
274
297
 
275
298
  begin
276
299
  # Build and execute the agent
@@ -291,7 +314,11 @@ module ActionAgent
291
314
  status: :failed,
292
315
  completed_at: Time.current,
293
316
  error_message: e.message,
294
- error_backtrace: e.backtrace&.first(10)&.join("\n")
317
+ error_backtrace: e.backtrace&.first(10)&.join("\n"),
318
+ # The class, not only the message: an agent that refused this
319
+ # caller and an agent that broke both fail the run, and only the
320
+ # class tells them apart without reading prose.
321
+ output_metadata: run.output_metadata.to_h.merge("error_class" => e.class.name)
295
322
  )
296
323
  end
297
324
 
@@ -317,14 +344,16 @@ module ActionAgent
317
344
 
318
345
  # Refuses files before creating anything: a run that exists but lost
319
346
  # its attachments would execute against the wrong prompt.
320
- def create_run(input_prompt, action:, attachments:, params:, **attributes)
347
+ def create_run(input_prompt, action:, attachments:, params:, actor: nil, **attributes)
321
348
  files = Array.wrap(attachments).compact
322
349
  raise AgentRun::AttachmentsUnavailable if files.any? && !AgentRun.attachments_available?
323
350
 
324
351
  run = agent_runs.create!(
325
352
  input_prompt: input_prompt,
326
353
  action_name: normalized_action(action),
327
- input_params: params,
354
+ # The caller is recorded beside the run's own parameters rather than
355
+ # among them: a client may send provider overrides, never an actor.
356
+ input_params: AgentRun.params_with_actor(params, actor),
328
357
  trace_id: SecureRandom.uuid,
329
358
  **attributes
330
359
  )
@@ -20,6 +20,55 @@ module ActionAgent
20
20
  # --skip-active-storage has no has_many_attached to call.
21
21
  has_many_attached :attachments if defined?(ActiveStorage)
22
22
 
23
+ # The key the caller's identity is recorded under in +input_params+.
24
+ # Underscored so it cannot collide with a provider override, and
25
+ # stripped from anything a client sends (see Api::AgentsController).
26
+ ACTOR_PARAM = "_actor_gid"
27
+
28
+ # +input_params+ with the caller recorded alongside them.
29
+ #
30
+ # The actor is stored as a Global ID rather than as the record, so the
31
+ # worker that picks the run up — on another machine, minutes later —
32
+ # authorizes as the same person who asked for the run. A caller the host
33
+ # cannot address that way (a plain object, a service account) is simply
34
+ # not recorded: the run then executes unattributed, which a host scope
35
+ # reads as "no access", rather than executing as somebody else.
36
+ #
37
+ # @param params [Hash] the run's own parameters
38
+ # @param actor [Object, nil] the caller
39
+ # @return [Hash]
40
+ def self.params_with_actor(params, actor)
41
+ params = (params || {}).to_h.except(ACTOR_PARAM, ACTOR_PARAM.to_sym)
42
+ gid = actor.respond_to?(:to_global_id) ? actor.to_global_id.to_s : nil
43
+ gid ? params.merge(ACTOR_PARAM => gid) : params
44
+ rescue StandardError => e
45
+ Rails.logger.warn("[AgentRun] could not record the run's actor: #{e.class} - #{e.message}")
46
+ params
47
+ end
48
+
49
+ # The caller this run executes on behalf of.
50
+ #
51
+ # Set in memory for a synchronous run; rehydrated from the stored Global
52
+ # ID for one picked up by a worker. A Global ID that no longer resolves
53
+ # (the user was deleted) yields nil, so the run loses access rather than
54
+ # inheriting someone else's.
55
+ # @return [Object, nil]
56
+ def actor
57
+ return @actor if defined?(@actor)
58
+
59
+ @actor = locate_actor
60
+ end
61
+
62
+ attr_writer :actor
63
+
64
+ # Whether this run knows who it is for. A run with a recorded actor that
65
+ # no longer resolves is *not* unattributed — it is broken, and callers
66
+ # that care can tell the two apart.
67
+ # @return [Boolean]
68
+ def actor_recorded?
69
+ input_params.is_a?(Hash) && input_params[ACTOR_PARAM].present?
70
+ end
71
+
23
72
  # Whether runs can carry files in this host app: Active Storage loaded,
24
73
  # the macro applied, and its tables migrated. Never raises — a host
25
74
  # that skipped `rails active_storage:install` still runs agents, it
@@ -220,6 +269,16 @@ module ActionAgent
220
269
 
221
270
  private
222
271
 
272
+ def locate_actor
273
+ return nil unless actor_recorded?
274
+ return nil unless defined?(GlobalID::Locator)
275
+
276
+ GlobalID::Locator.locate(input_params[ACTOR_PARAM])
277
+ rescue StandardError => e
278
+ Rails.logger.warn("[AgentRun] could not resolve the run's actor: #{e.class} - #{e.message}")
279
+ nil
280
+ end
281
+
223
282
  def set_trace_id
224
283
  self.trace_id ||= SecureRandom.uuid
225
284
  end
@@ -45,6 +45,13 @@ module ActionAgent
45
45
  new(agent_record, run).call
46
46
  end
47
47
 
48
+ # Tool-call keywords that name the caller. The model's arguments and the
49
+ # run's actor share one keyword namespace by the time they reach a tool,
50
+ # so anything a model emits under these names is dropped before the call:
51
+ # an actor a model can name is not an authorization boundary, and the
52
+ # documents a model reads are attacker-reachable.
53
+ ACTOR_KEYWORDS = %i[actor current_user].freeze
54
+
48
55
  def initialize(agent_record, run)
49
56
  @agent_record = agent_record
50
57
  @run = run
@@ -52,6 +59,14 @@ module ActionAgent
52
59
  @event_sequence = 0
53
60
  end
54
61
 
62
+ # The caller this run executes on behalf of, or nil when it runs
63
+ # unattributed. Passed to every tool as +actor:+ — a host's SchemaTools
64
+ # scope block, Pundit policy or agent callback decides what that means.
65
+ # @return [Object, nil]
66
+ def actor
67
+ @run.actor
68
+ end
69
+
55
70
  # Emits a progress event on the run (streamed to the UI by pollers).
56
71
  # Never lets telemetry break execution.
57
72
  def emit_event(**kwargs)
@@ -266,6 +281,12 @@ module ActionAgent
266
281
  # execution) and recorded in @tool_invocations so tool names, arguments
267
282
  # and durations reach Traces and the persisted conversation.
268
283
  def execute_tool(name, **kwargs)
284
+ forged = kwargs.slice(*ACTOR_KEYWORDS)
285
+ if forged.any?
286
+ Rails.logger.warn("[AgentExecutionService] dropped caller-named arguments from #{name}: #{forged.keys.join(', ')}")
287
+ kwargs = kwargs.except(*ACTOR_KEYWORDS)
288
+ end
289
+
269
290
  # Record the absolute URL browse_page will actually fetch, not the bare
270
291
  # path the model passed — spans/events/persisted args stay unambiguous.
271
292
  kwargs[:url] = AgentToolbox.resolve_browse_url(kwargs[:url]) if name.to_s == "browse_page" && kwargs[:url]
@@ -309,7 +330,9 @@ module ActionAgent
309
330
  else
310
331
  # A tool one of the agent's own MCP servers serves is called there;
311
332
  # AgentToolbox answers the rest.
312
- mcp_dispatcher.call(name, kwargs) || AgentToolbox.call(name, **kwargs)
333
+ # `actor:` comes from the run, never from kwargs (see
334
+ # ACTOR_KEYWORDS): it is who the run is for, not what it is about.
335
+ mcp_dispatcher.call(name, kwargs) || AgentToolbox.call(name, actor: actor, **kwargs)
313
336
  end
314
337
  rescue StandardError => e
315
338
  Rails.logger.warn("[AgentExecutionService] Tool #{name} failed: #{e.class} - #{e.message}")
@@ -529,7 +552,10 @@ module ActionAgent
529
552
  private :persist_tool_messages_to_context
530
553
  end
531
554
 
532
- agent_class.public_send(action).generate_now
555
+ # `as` carries the caller onto the agent instance, so an agent's own
556
+ # before_action callbacks (ActiveAgent::Authorization) authorize
557
+ # against the same person the tools are scoped to.
558
+ agent_class.as(actor).public_send(action).generate_now
533
559
  end
534
560
 
535
561
  # Function-calling schemas for the agent's enabled tools that have