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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +54 -54
- data/app/controllers/action_agent/api/agents_controller.rb +39 -5
- data/app/controllers/action_agent/api/base_controller.rb +17 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +115 -4
- data/app/jobs/action_agent/agent_execution_job.rb +4 -1
- data/app/models/action_agent/agent.rb +36 -7
- data/app/models/action_agent/agent_run.rb +59 -0
- data/app/services/action_agent/agent_execution_service.rb +28 -2
- data/app/services/action_agent/agent_tool_roster.rb +269 -0
- data/app/services/action_agent/agent_toolbox.rb +12 -3
- data/app/services/action_agent/scenario_evaluation_runner.rb +17 -1
- data/config/routes.rb +3 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +66 -4
- metadata +4 -6
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|