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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +54 -54
  4. data/app/controllers/action_agent/api/agent_runs_controller.rb +2 -2
  5. data/app/controllers/action_agent/api/agents_controller.rb +47 -10
  6. data/app/controllers/action_agent/api/analytics_controller.rb +1 -1
  7. data/app/controllers/action_agent/api/base_controller.rb +37 -0
  8. data/app/controllers/action_agent/api/interactions_controller.rb +3 -3
  9. data/app/controllers/action_agent/api/mcp_controller.rb +115 -4
  10. data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -1
  11. data/app/controllers/action_agent/api/session_recordings_controller.rb +24 -7
  12. data/app/jobs/action_agent/agent_execution_job.rb +4 -1
  13. data/app/models/action_agent/agent.rb +88 -7
  14. data/app/models/action_agent/agent_run.rb +63 -0
  15. data/app/models/action_agent/agent_version.rb +10 -0
  16. data/app/models/action_agent/evaluation.rb +11 -0
  17. data/app/models/action_agent/evaluation_run.rb +4 -0
  18. data/app/models/action_agent/telemetry_trace.rb +28 -1
  19. data/app/services/action_agent/agent_execution_service.rb +28 -2
  20. data/app/services/action_agent/agent_registrar.rb +23 -1
  21. data/app/services/action_agent/agent_release.rb +61 -0
  22. data/app/services/action_agent/agent_tool_roster.rb +295 -0
  23. data/app/services/action_agent/agent_toolbox.rb +12 -3
  24. data/app/services/action_agent/evaluation_runner_service.rb +11 -9
  25. data/app/services/action_agent/scenario_evaluation_runner.rb +17 -1
  26. data/config/routes.rb +6 -0
  27. data/lib/action_agent/version.rb +1 -1
  28. data/lib/action_agent.rb +66 -4
  29. data/lib/generators/action_agent/install_generator.rb +6 -0
  30. data/lib/generators/action_agent/templates/action_agent.rb.erb +8 -0
  31. data/lib/generators/action_agent/templates/add_agent_releases.rb.erb +50 -0
  32. data/lib/tasks/action_agent.rake +33 -0
  33. 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 = (params[:page] || 1).to_i
37
- per_page = (params[:per_page] || 20).to_i
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
- 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 ]
@@ -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 = params[:minutes].presence&.then { |m| m.to_i.clamp(1, 60 * 24 * 90) }
133
- page = (params[:page] || 1).to_i
134
- per_page = (params[:per_page] || 20).to_i
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 = (params[:days] || 30).to_i
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 = (params[:days] || 30).to_i
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 = params.fetch(:limit, DEFAULT_LIMIT).to_i.clamp(1, 200)
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 = params[:minutes].presence
144
- @window_minutes = raw ? raw.to_i.clamp(1, MAX_WINDOW_MINUTES) : nil
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. 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|
@@ -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] || %w[anthropic openai ollama]
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 = (params[:page] || 1).to_i
40
- per_page = [ (params[:per_page] || 20).to_i, 100 ].min
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 > ?", params[:after_sequence].to_i)
88
+ actions = actions.where("sequence > ?", integer_param(:after_sequence, default: 0))
83
89
  end
84
90
 
85
- limit = [ params[:limit]&.to_i || 100, 500 ].min
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
- # Remove sensitive data from metadata
348
- metadata.except("cookies", "session_storage", "local_storage")
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(input_prompt, action: action, attachments: attachments, params: params, status: :pending)
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
- input_params: params,
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,