actionagent 1.5.0 → 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 ]
@@ -66,7 +69,7 @@ module ActionAgent
66
69
  providers: Agent::PROVIDERS,
67
70
  preset_types: Agent::PRESET_TYPES,
68
71
  instruction_sets: Agent::INSTRUCTION_SETS,
69
- available_tools: Agent::AVAILABLE_TOOLS
72
+ available_tools: Agent.available_tools
70
73
  }
71
74
  }
72
75
  end
@@ -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|
@@ -41,7 +41,7 @@ module ActionAgent
41
41
  providers: Agent::PROVIDERS,
42
42
  presetTypes: Agent::PRESET_TYPES,
43
43
  instructionSets: Agent::INSTRUCTION_SETS,
44
- availableTools: Agent::AVAILABLE_TOOLS,
44
+ availableTools: Agent.available_tools,
45
45
  executionEnabled: ActionAgent.execution_enabled?,
46
46
  assistantEnabled: ActionAgent.assistant_enabled?,
47
47
  multiTenant: ActionAgent.multi_tenant?,
@@ -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
@@ -41,6 +41,7 @@ module ActionAgent
41
41
 
42
42
  # Callbacks
43
43
  before_validation :generate_slug, on: :create
44
+ before_validation :apply_conventional_schema_tools, on: :create
44
45
  after_create :create_initial_version
45
46
  after_update :create_version_on_config_change, if: :configuration_changed?
46
47
 
@@ -72,17 +73,81 @@ module ActionAgent
72
73
  github ruby rails aws gcp python typescript docker kubernetes
73
74
  ].freeze
74
75
 
75
- # Available tools/MCPs
76
+ # Built-in tools/MCPs. Host-declared schema tools are offered alongside
77
+ # these — see .available_tools, which is what the editor and the APIs
78
+ # serialize. This constant stays the built-in set so existing references
79
+ # keep their meaning.
76
80
  AVAILABLE_TOOLS = %w[
77
81
  terminal playwright filesystem code database slack fetch search edit translate memory agents ui
78
82
  ].freeze
79
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
+
103
+ # Every tool an agent may enable: the built-ins plus each tool generated by
104
+ # the host's declared ActiveAgent::SchemaTools classes (ActionAgent.schema_tools).
105
+ #
106
+ # Computed per call, never memoized: in development the host's tool classes
107
+ # are autoloaded and reloaded, so a cached list would either miss them at
108
+ # boot or go stale after a reload.
109
+ # @return [Array<String>]
110
+ def self.available_tools
111
+ AVAILABLE_TOOLS | ActionAgent.schema_tool_names
112
+ end
113
+
80
114
  # Available providers
81
115
  PROVIDERS = %w[openai anthropic ollama openrouter].freeze
82
116
 
83
117
  # The ActiveAgent class name this agent's runs are recorded under — the
84
118
  # correlation key between platform Agent records and telemetry traces
85
119
  # (TelemetryTrace#agent_class) and solid_agent contexts.
120
+ # Tools a schema tool class claims for this agent by naming convention:
121
+ # Reservation -> ReservationTools -> ReservationAgent.
122
+ #
123
+ # This is a DEFAULT SELECTION, never a restriction. Any agent may enable
124
+ # any tool in .available_tools; the convention only decides what a newly
125
+ # created ReservationAgent starts with.
126
+ # @return [Array<String>]
127
+ def conventional_schema_tools
128
+ # Compared on letters only. `telemetry_agent_class` is not reliable here:
129
+ # it runs `parameterize.camelize`, which turns an already-camelised
130
+ # "TicketAgent" into "Ticketagent" and matches nothing, while
131
+ # "Milestone Agent" happens to survive. Normalising both sides makes
132
+ # "TicketAgent", "Ticket Agent" and "ticket_agent" all match.
133
+ identifier = (agent_class_name.presence || name.to_s).gsub(/[^a-z]/i, "").downcase
134
+ return [] if identifier.blank?
135
+
136
+ ActionAgent.schema_tool_classes.select do |klass|
137
+ "#{klass.model.name}Agent".downcase == identifier
138
+ end.flat_map(&:tool_names).map(&:to_s)
139
+ end
140
+
141
+ # Seeds a new agent named after a model with that model's tools. Only on
142
+ # create, and only when none were chosen — a deliberate selection, empty
143
+ # included, is never overwritten, and the editor can deselect afterwards.
144
+ def apply_conventional_schema_tools
145
+ return if tools.present?
146
+
147
+ defaults = conventional_schema_tools
148
+ self.tools = defaults if defaults.any?
149
+ end
150
+
86
151
  def telemetry_agent_class
87
152
  base = agent_class_name.presence || name.parameterize(separator: "_").camelize
88
153
  base.end_with?("Agent") ? base : "#{base}Agent"
@@ -208,9 +273,12 @@ module ActionAgent
208
273
  # before the job is enqueued, so a worker on another machine finds them
209
274
  # attached. +params+ (provider/model overrides, the context_id of a
210
275
  # conversation to continue) are kept on the run as input_params.
211
- def execute(input_prompt, action: nil, attachments: [], **params)
276
+ def execute(input_prompt, action: nil, attachments: [], actor: nil, **params)
212
277
  ensure_executable!
213
- 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
+ )
214
282
 
215
283
  # Queue the execution job
216
284
  AgentExecutionJob.perform_later(run.id)
@@ -219,12 +287,13 @@ module ActionAgent
219
287
  end
220
288
 
221
289
  # Quick test execution (synchronous)
222
- def test_execute(input_prompt, action: nil, attachments: [], **params)
290
+ def test_execute(input_prompt, action: nil, attachments: [], actor: nil, **params)
223
291
  ensure_executable!
224
292
  run = create_run(
225
293
  input_prompt, action: action, attachments: attachments, params: params,
226
- status: :running, started_at: Time.current
294
+ actor: actor, status: :running, started_at: Time.current
227
295
  )
296
+ run.actor = actor
228
297
 
229
298
  begin
230
299
  # Build and execute the agent
@@ -245,7 +314,11 @@ module ActionAgent
245
314
  status: :failed,
246
315
  completed_at: Time.current,
247
316
  error_message: e.message,
248
- 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)
249
322
  )
250
323
  end
251
324
 
@@ -271,14 +344,16 @@ module ActionAgent
271
344
 
272
345
  # Refuses files before creating anything: a run that exists but lost
273
346
  # its attachments would execute against the wrong prompt.
274
- def create_run(input_prompt, action:, attachments:, params:, **attributes)
347
+ def create_run(input_prompt, action:, attachments:, params:, actor: nil, **attributes)
275
348
  files = Array.wrap(attachments).compact
276
349
  raise AgentRun::AttachmentsUnavailable if files.any? && !AgentRun.attachments_available?
277
350
 
278
351
  run = agent_runs.create!(
279
352
  input_prompt: input_prompt,
280
353
  action_name: normalized_action(action),
281
- 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),
282
357
  trace_id: SecureRandom.uuid,
283
358
  **attributes
284
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