actionagent 1.5.2 → 1.6.2
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/agent_runs_controller.rb +2 -2
- data/app/controllers/action_agent/api/agents_controller.rb +47 -10
- data/app/controllers/action_agent/api/analytics_controller.rb +1 -1
- data/app/controllers/action_agent/api/base_controller.rb +37 -0
- data/app/controllers/action_agent/api/interactions_controller.rb +3 -3
- data/app/controllers/action_agent/api/mcp_controller.rb +115 -4
- data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -1
- data/app/controllers/action_agent/api/session_recordings_controller.rb +24 -7
- data/app/jobs/action_agent/agent_execution_job.rb +4 -1
- data/app/models/action_agent/agent.rb +88 -7
- data/app/models/action_agent/agent_run.rb +63 -0
- data/app/models/action_agent/agent_version.rb +10 -0
- data/app/models/action_agent/evaluation.rb +11 -0
- data/app/models/action_agent/evaluation_run.rb +4 -0
- data/app/models/action_agent/telemetry_trace.rb +28 -1
- data/app/services/action_agent/agent_execution_service.rb +28 -2
- data/app/services/action_agent/agent_registrar.rb +23 -1
- data/app/services/action_agent/agent_release.rb +61 -0
- data/app/services/action_agent/agent_tool_roster.rb +295 -0
- data/app/services/action_agent/agent_toolbox.rb +12 -3
- data/app/services/action_agent/evaluation_runner_service.rb +11 -9
- data/app/services/action_agent/scenario_evaluation_runner.rb +17 -1
- data/config/routes.rb +6 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +66 -4
- data/lib/generators/action_agent/install_generator.rb +6 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +8 -0
- data/lib/generators/action_agent/templates/add_agent_releases.rb.erb +50 -0
- data/lib/tasks/action_agent.rake +33 -0
- metadata +5 -2
|
@@ -33,8 +33,8 @@ module ActionAgent
|
|
|
33
33
|
scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
|
|
34
34
|
scope = scope.where(status: params[:status]) if params[:status].present?
|
|
35
35
|
|
|
36
|
-
page = (
|
|
37
|
-
per_page = (
|
|
36
|
+
page = integer_param(:page, default: 1)
|
|
37
|
+
per_page = integer_param(:per_page, default: 20)
|
|
38
38
|
total = scope.count
|
|
39
39
|
runs = scope.offset((page - 1) * per_page).limit(per_page)
|
|
40
40
|
|
|
@@ -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 ]
|
|
@@ -129,9 +132,9 @@ module ActionAgent
|
|
|
129
132
|
# observed from telemetry have no AgentRun rows at all, so a runs-only
|
|
130
133
|
# list showed them as empty while their scorecard reported real traffic.
|
|
131
134
|
def runs
|
|
132
|
-
minutes =
|
|
133
|
-
page = (
|
|
134
|
-
per_page = (
|
|
135
|
+
minutes = integer_param(:minutes)&.clamp(1, 60 * 24 * 90)
|
|
136
|
+
page = integer_param(:page, default: 1)
|
|
137
|
+
per_page = integer_param(:per_page, default: 20)
|
|
135
138
|
|
|
136
139
|
executions = AgentExecutions.new(
|
|
137
140
|
agents: [ @agent ],
|
|
@@ -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
|
|
@@ -264,7 +282,7 @@ module ActionAgent
|
|
|
264
282
|
# with all-zero metrics beside a card and a runs list reporting real
|
|
265
283
|
# traffic.
|
|
266
284
|
def analytics
|
|
267
|
-
days = (
|
|
285
|
+
days = integer_param(:days, default: 30)
|
|
268
286
|
start_date = days.days.ago.beginning_of_day
|
|
269
287
|
|
|
270
288
|
runs = @agent.agent_runs.where("created_at >= ?", start_date)
|
|
@@ -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)
|
|
@@ -498,7 +532,10 @@ module ActionAgent
|
|
|
498
532
|
change_summary: version.change_summary,
|
|
499
533
|
created_by: version.created_by,
|
|
500
534
|
created_at: version.created_at,
|
|
501
|
-
is_latest: version.latest
|
|
535
|
+
is_latest: version.latest?,
|
|
536
|
+
release: version.release?,
|
|
537
|
+
release_digest: version.release_digest,
|
|
538
|
+
revision: version.revision
|
|
502
539
|
}
|
|
503
540
|
|
|
504
541
|
if include_diff && version.previous
|
|
@@ -5,7 +5,7 @@ module ActionAgent
|
|
|
5
5
|
class AnalyticsController < BaseController
|
|
6
6
|
# GET /api/analytics
|
|
7
7
|
def index
|
|
8
|
-
days = (
|
|
8
|
+
days = integer_param(:days, default: 30)
|
|
9
9
|
start_date = days.days.ago.beginning_of_day
|
|
10
10
|
|
|
11
11
|
# Table names are interpolated rather than written literally: the
|
|
@@ -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
|
|
@@ -112,6 +129,26 @@ module ActionAgent
|
|
|
112
129
|
render json: { error: "Agent execution is disabled on this dashboard" }, status: :forbidden
|
|
113
130
|
end
|
|
114
131
|
|
|
132
|
+
# An integer query param. A value can arrive as a container
|
|
133
|
+
# (`minutes[]=1&minutes[]=2`, or `page[x]=1`), and neither Array nor
|
|
134
|
+
# ActionController::Parameters responds to `to_i`: reading them
|
|
135
|
+
# directly raised NoMethodError and turned a malformed query into a
|
|
136
|
+
# 500. A multi-valued param means its first value; anything else that
|
|
137
|
+
# is not a scalar falls back to the default.
|
|
138
|
+
def integer_param(name, default: nil)
|
|
139
|
+
raw = params[name]
|
|
140
|
+
raw = raw.first if raw.is_a?(Array)
|
|
141
|
+
return default if raw.blank? || !(raw.is_a?(String) || raw.is_a?(Numeric))
|
|
142
|
+
|
|
143
|
+
raw.to_s.to_i
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# integer_param, then clamped into [min, max]. Non-numeric input becomes
|
|
147
|
+
# 0 and is then clamped up to `min`.
|
|
148
|
+
def clamped_param(name, default:, min:, max:)
|
|
149
|
+
integer_param(name, default: default).clamp(min, max)
|
|
150
|
+
end
|
|
151
|
+
|
|
115
152
|
def not_found
|
|
116
153
|
render json: { error: "Record not found" }, status: :not_found
|
|
117
154
|
end
|
|
@@ -16,7 +16,7 @@ module ActionAgent
|
|
|
16
16
|
|
|
17
17
|
# GET /api/interactions
|
|
18
18
|
def index
|
|
19
|
-
limit =
|
|
19
|
+
limit = clamped_param(:limit, default: DEFAULT_LIMIT, min: 1, max: 200)
|
|
20
20
|
|
|
21
21
|
contexts = interactions_scope
|
|
22
22
|
.includes(:contextable)
|
|
@@ -140,8 +140,8 @@ module ActionAgent
|
|
|
140
140
|
def window_minutes
|
|
141
141
|
return @window_minutes if defined?(@window_minutes)
|
|
142
142
|
|
|
143
|
-
raw =
|
|
144
|
-
@window_minutes = raw ? raw.
|
|
143
|
+
raw = integer_param(:minutes)
|
|
144
|
+
@window_minutes = raw ? raw.clamp(1, MAX_WINDOW_MINUTES) : nil
|
|
145
145
|
end
|
|
146
146
|
|
|
147
147
|
def interactions_scope
|
|
@@ -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|
|
|
@@ -18,11 +18,16 @@ module ActionAgent
|
|
|
18
18
|
# POST /api/sandboxes/compare
|
|
19
19
|
# Run multiple providers in a single sandbox using parallel generation jobs
|
|
20
20
|
def compare
|
|
21
|
-
providers = params[:providers]
|
|
21
|
+
providers = params[:providers].nil? ? %w[anthropic openai ollama] : params[:providers]
|
|
22
22
|
task = params[:task]
|
|
23
23
|
sandbox_id = params[:sandbox_id]
|
|
24
24
|
|
|
25
25
|
return render json: { error: "Task required" }, status: :bad_request unless task.present?
|
|
26
|
+
# A bare string or a nested object is a malformed request, not a list
|
|
27
|
+
# of one provider: reading it as a list raised NoMethodError.
|
|
28
|
+
unless providers.is_a?(Array) && providers.all? { |name| name.is_a?(String) }
|
|
29
|
+
return render json: { error: "providers must be a list of provider names" }, status: :bad_request
|
|
30
|
+
end
|
|
26
31
|
return render json: { error: "At least 2 providers required" }, status: :bad_request if providers.size < 2
|
|
27
32
|
|
|
28
33
|
# Validate providers
|
|
@@ -13,6 +13,12 @@ module ActionAgent
|
|
|
13
13
|
|
|
14
14
|
before_action :set_recording, only: [ :show, :actions, :snapshot, :export, :handoff ]
|
|
15
15
|
|
|
16
|
+
# Browser state that must never leave the server in a read response:
|
|
17
|
+
# the handoff state a recording carries is a copy of the visitor's
|
|
18
|
+
# cookies and web storage. Only #handoff returns it, to the owner, when
|
|
19
|
+
# they continue the session.
|
|
20
|
+
SENSITIVE_STATE_KEYS = %w[cookies session_storage local_storage].freeze
|
|
21
|
+
|
|
16
22
|
# GET /api/session_recordings
|
|
17
23
|
# List recordings with optional filters
|
|
18
24
|
def index
|
|
@@ -36,8 +42,8 @@ module ActionAgent
|
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
# Pagination
|
|
39
|
-
page = (
|
|
40
|
-
per_page = [ (
|
|
45
|
+
page = integer_param(:page, default: 1)
|
|
46
|
+
per_page = [ integer_param(:per_page, default: 20), 100 ].min
|
|
41
47
|
offset = (page - 1) * per_page
|
|
42
48
|
|
|
43
49
|
total = recordings.count
|
|
@@ -79,10 +85,10 @@ module ActionAgent
|
|
|
79
85
|
|
|
80
86
|
# Support pagination for large recordings
|
|
81
87
|
if params[:after_sequence].present?
|
|
82
|
-
actions = actions.where("sequence > ?",
|
|
88
|
+
actions = actions.where("sequence > ?", integer_param(:after_sequence, default: 0))
|
|
83
89
|
end
|
|
84
90
|
|
|
85
|
-
limit = [
|
|
91
|
+
limit = [ integer_param(:limit, default: 100), 500 ].min
|
|
86
92
|
actions = actions.limit(limit)
|
|
87
93
|
|
|
88
94
|
render json: {
|
|
@@ -332,7 +338,7 @@ module ActionAgent
|
|
|
332
338
|
created_at: recording.created_at.iso8601,
|
|
333
339
|
updated_at: recording.updated_at.iso8601,
|
|
334
340
|
timeline: recording.timeline,
|
|
335
|
-
handoff_state: recording.metadata["handoff_state"],
|
|
341
|
+
handoff_state: safe_handoff_state(recording.metadata["handoff_state"]),
|
|
336
342
|
agent: recording.agent_run&.agent&.slice(:id, :name),
|
|
337
343
|
sandbox_session: recording.sandbox_session&.summary
|
|
338
344
|
}
|
|
@@ -343,9 +349,20 @@ module ActionAgent
|
|
|
343
349
|
action&.screenshot_url(expires_in: 1.hour)
|
|
344
350
|
end
|
|
345
351
|
|
|
352
|
+
# Strips the browser state at the top level and inside handoff_state,
|
|
353
|
+
# which the model stores nested (a recording's metadata carries the
|
|
354
|
+
# handoff as one key), so a show response never ships a session cookie.
|
|
346
355
|
def safe_metadata(metadata)
|
|
347
|
-
|
|
348
|
-
|
|
356
|
+
safe = (metadata || {}).except(*SENSITIVE_STATE_KEYS)
|
|
357
|
+
return safe unless safe["handoff_state"].is_a?(Hash)
|
|
358
|
+
|
|
359
|
+
safe.merge("handoff_state" => safe_handoff_state(safe["handoff_state"]))
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def safe_handoff_state(handoff_state)
|
|
363
|
+
return handoff_state unless handoff_state.is_a?(Hash)
|
|
364
|
+
|
|
365
|
+
handoff_state.except(*SENSITIVE_STATE_KEYS)
|
|
349
366
|
end
|
|
350
367
|
|
|
351
368
|
def generate_visitor_id
|
|
@@ -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
|
#
|
|
@@ -200,6 +219,46 @@ module ActionAgent
|
|
|
200
219
|
agent_versions.order(version_number: :desc).first
|
|
201
220
|
end
|
|
202
221
|
|
|
222
|
+
# The most recent version cut from the agent's code, if any.
|
|
223
|
+
# @return [AgentVersion, nil]
|
|
224
|
+
def latest_release
|
|
225
|
+
agent_versions.releases.order(version_number: :desc).first
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Cuts a version for a release of the agent's code, identified by the
|
|
229
|
+
# digest ActiveAgent::Release computes from what the model is given.
|
|
230
|
+
# Returns the existing version when the latest release already carries
|
|
231
|
+
# this digest — a redeploy of an unchanged agent is not a new version —
|
|
232
|
+
# so it is safe to call on every deploy.
|
|
233
|
+
#
|
|
234
|
+
# The version's snapshot is the dashboard configuration plus the release
|
|
235
|
+
# manifest under "release", so the Versions tab can diff two releases the
|
|
236
|
+
# same way it diffs two dashboard edits.
|
|
237
|
+
#
|
|
238
|
+
# @param digest [String] ActiveAgent::Release digest of the host class
|
|
239
|
+
# @param manifest [Hash, nil] the class's release manifest
|
|
240
|
+
# @param revision [String, nil] the deploy (git SHA, release label)
|
|
241
|
+
# @param released_by [String, nil]
|
|
242
|
+
# @return [AgentVersion]
|
|
243
|
+
def record_release!(digest:, manifest: nil, revision: nil, released_by: nil)
|
|
244
|
+
current = latest_release
|
|
245
|
+
if current && current.release_digest == digest
|
|
246
|
+
update_columns(release_digest: digest) if release_digest != digest
|
|
247
|
+
return current
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
version = agent_versions.create!(
|
|
251
|
+
version_number: (latest_version&.version_number || 0) + 1,
|
|
252
|
+
change_summary: release_summary(digest, revision, current&.configuration_snapshot&.dig("release"), manifest),
|
|
253
|
+
configuration_snapshot: configuration_snapshot.merge("release" => manifest || {}),
|
|
254
|
+
release_digest: digest,
|
|
255
|
+
revision: revision,
|
|
256
|
+
created_by: released_by || "release"
|
|
257
|
+
)
|
|
258
|
+
update_columns(release_digest: digest)
|
|
259
|
+
version
|
|
260
|
+
end
|
|
261
|
+
|
|
203
262
|
# Maps each historical instructions digest to the first version that
|
|
204
263
|
# introduced it ("v3"), so run cohorts can label instruction changes with
|
|
205
264
|
# real agent versions instead of raw hashes.
|
|
@@ -254,9 +313,12 @@ module ActionAgent
|
|
|
254
313
|
# before the job is enqueued, so a worker on another machine finds them
|
|
255
314
|
# attached. +params+ (provider/model overrides, the context_id of a
|
|
256
315
|
# conversation to continue) are kept on the run as input_params.
|
|
257
|
-
def execute(input_prompt, action: nil, attachments: [], **params)
|
|
316
|
+
def execute(input_prompt, action: nil, attachments: [], actor: nil, **params)
|
|
258
317
|
ensure_executable!
|
|
259
|
-
run = create_run(
|
|
318
|
+
run = create_run(
|
|
319
|
+
input_prompt, action: action, attachments: attachments, params: params,
|
|
320
|
+
actor: actor, status: :pending
|
|
321
|
+
)
|
|
260
322
|
|
|
261
323
|
# Queue the execution job
|
|
262
324
|
AgentExecutionJob.perform_later(run.id)
|
|
@@ -265,12 +327,13 @@ module ActionAgent
|
|
|
265
327
|
end
|
|
266
328
|
|
|
267
329
|
# Quick test execution (synchronous)
|
|
268
|
-
def test_execute(input_prompt, action: nil, attachments: [], **params)
|
|
330
|
+
def test_execute(input_prompt, action: nil, attachments: [], actor: nil, **params)
|
|
269
331
|
ensure_executable!
|
|
270
332
|
run = create_run(
|
|
271
333
|
input_prompt, action: action, attachments: attachments, params: params,
|
|
272
|
-
status: :running, started_at: Time.current
|
|
334
|
+
actor: actor, status: :running, started_at: Time.current
|
|
273
335
|
)
|
|
336
|
+
run.actor = actor
|
|
274
337
|
|
|
275
338
|
begin
|
|
276
339
|
# Build and execute the agent
|
|
@@ -291,7 +354,11 @@ module ActionAgent
|
|
|
291
354
|
status: :failed,
|
|
292
355
|
completed_at: Time.current,
|
|
293
356
|
error_message: e.message,
|
|
294
|
-
error_backtrace: e.backtrace&.first(10)&.join("\n")
|
|
357
|
+
error_backtrace: e.backtrace&.first(10)&.join("\n"),
|
|
358
|
+
# The class, not only the message: an agent that refused this
|
|
359
|
+
# caller and an agent that broke both fail the run, and only the
|
|
360
|
+
# class tells them apart without reading prose.
|
|
361
|
+
output_metadata: run.output_metadata.to_h.merge("error_class" => e.class.name)
|
|
295
362
|
)
|
|
296
363
|
end
|
|
297
364
|
|
|
@@ -317,14 +384,16 @@ module ActionAgent
|
|
|
317
384
|
|
|
318
385
|
# Refuses files before creating anything: a run that exists but lost
|
|
319
386
|
# its attachments would execute against the wrong prompt.
|
|
320
|
-
def create_run(input_prompt, action:, attachments:, params:, **attributes)
|
|
387
|
+
def create_run(input_prompt, action:, attachments:, params:, actor: nil, **attributes)
|
|
321
388
|
files = Array.wrap(attachments).compact
|
|
322
389
|
raise AgentRun::AttachmentsUnavailable if files.any? && !AgentRun.attachments_available?
|
|
323
390
|
|
|
324
391
|
run = agent_runs.create!(
|
|
325
392
|
input_prompt: input_prompt,
|
|
326
393
|
action_name: normalized_action(action),
|
|
327
|
-
|
|
394
|
+
# The caller is recorded beside the run's own parameters rather than
|
|
395
|
+
# among them: a client may send provider overrides, never an actor.
|
|
396
|
+
input_params: AgentRun.params_with_actor(params, actor),
|
|
328
397
|
trace_id: SecureRandom.uuid,
|
|
329
398
|
**attributes
|
|
330
399
|
)
|
|
@@ -367,6 +436,18 @@ module ActionAgent
|
|
|
367
436
|
end
|
|
368
437
|
end
|
|
369
438
|
|
|
439
|
+
# "Release 1a2b3c4d5e6f · abc1234: templates, tools" — the digest, the
|
|
440
|
+
# deploy, and which parts of the manifest moved since the last release.
|
|
441
|
+
def release_summary(digest, revision, previous_manifest, manifest)
|
|
442
|
+
label = [ "Release #{digest}", revision.presence ].compact.join(" · ")
|
|
443
|
+
return "#{label}: first release" if previous_manifest.blank? || manifest.blank?
|
|
444
|
+
|
|
445
|
+
changed = (previous_manifest.keys | manifest.stringify_keys.keys).select do |key|
|
|
446
|
+
previous_manifest[key] != manifest.stringify_keys[key]
|
|
447
|
+
end
|
|
448
|
+
changed.any? ? "#{label}: #{changed.sort.join(', ')}" : label
|
|
449
|
+
end
|
|
450
|
+
|
|
370
451
|
def create_initial_version
|
|
371
452
|
agent_versions.create!(
|
|
372
453
|
version_number: 1,
|