actionagent 1.3.0 → 1.5.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 +70 -48
  4. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  5. data/app/controllers/action_agent/api/agents_controller.rb +117 -10
  6. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
  7. data/app/controllers/action_agent/api/evaluations_controller.rb +22 -7
  8. data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
  9. data/app/controllers/action_agent/dashboard_controller.rb +2 -1
  10. data/app/models/action_agent/agent.rb +107 -19
  11. data/app/models/action_agent/agent_run.rb +99 -0
  12. data/app/models/action_agent/evaluation_run.rb +14 -6
  13. data/app/models/action_agent/evaluation_scenario_result.rb +24 -4
  14. data/app/models/action_agent/telemetry_trace.rb +16 -1
  15. data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
  16. data/app/services/action_agent/agent_execution_service.rb +294 -16
  17. data/app/services/action_agent/agent_registrar.rb +7 -6
  18. data/app/services/action_agent/agent_toolbox.rb +69 -5
  19. data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
  20. data/app/services/action_agent/evaluation_evidence.rb +234 -0
  21. data/app/services/action_agent/evaluation_runner_service.rb +13 -3
  22. data/app/services/action_agent/evaluation_tool_resolver.rb +10 -2
  23. data/app/services/action_agent/mcp_client.rb +167 -0
  24. data/app/services/action_agent/mcp_tool_dispatcher.rb +148 -0
  25. data/app/services/action_agent/playwright_mcp_client.rb +11 -126
  26. data/app/services/action_agent/scenario_evaluation_runner.rb +90 -17
  27. data/config/routes.rb +11 -1
  28. data/lib/action_agent/assistant_request_filter.rb +22 -0
  29. data/lib/action_agent/engine.rb +5 -0
  30. data/lib/action_agent/version.rb +1 -1
  31. data/lib/action_agent.rb +113 -0
  32. data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
  33. metadata +9 -2
@@ -28,7 +28,7 @@ module ActionAgent
28
28
  def index
29
29
  # Scoped to the caller's own agents: this listed (and counted) every
30
30
  # run in the database regardless of who owned it.
31
- scope = AgentRun.includes(:agent).where(agent: owner_agents).recent
31
+ scope = AgentRun.includes(:agent).with_attachments.where(agent: owner_agents).recent
32
32
 
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?
@@ -125,6 +125,8 @@ module ActionAgent
125
125
  total_tokens: run.total_tokens,
126
126
  error_message: run.error_message,
127
127
  trace_id: run.trace_id,
128
+ attachments: run.attachment_manifest,
129
+ context_id: run.context_id,
128
130
  logs: run.logs,
129
131
  started_at: run.started_at,
130
132
  completed_at: run.completed_at,
@@ -16,12 +16,23 @@ module ActionAgent
16
16
  "tokens" => "Most tokens"
17
17
  }.freeze
18
18
  DEFAULT_LIST_SORT = "recent"
19
-
20
- before_action :set_agent, only: [ :show, :update, :destroy, :versions, :runs, :execute, :test, :restore, :duplicate, :export, :analytics ]
19
+ # Conversations returned to the runner's picker when no limit is asked for.
20
+ CONVERSATIONS_LIMIT = 50
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
24
+
25
+ before_action :set_agent, only: [
26
+ :show, :update, :destroy, :versions, :runs, :execute, :test, :restore, :duplicate, :export, :analytics,
27
+ :conversations, :create_conversation
28
+ ]
21
29
  before_action :require_execution_enabled!, only: [ :execute, :test ]
22
30
  before_action :require_owner!, only: [ :execute, :test ]
23
- before_action :require_executable_agent!, only: [ :execute, :test ]
31
+ before_action :require_executable_agent!, only: [ :execute, :test, :update, :restore, :create_conversation ]
24
32
  before_action :enforce_execution_quota!, only: [ :execute, :test ]
33
+ before_action :require_prompt!, only: [ :execute, :test ]
34
+
35
+ rescue_from AgentRun::AttachmentsUnavailable, with: :attachments_unavailable
25
36
 
26
37
  # GET /api/agents
27
38
  def index
@@ -55,7 +66,7 @@ module ActionAgent
55
66
  providers: Agent::PROVIDERS,
56
67
  preset_types: Agent::PRESET_TYPES,
57
68
  instruction_sets: Agent::INSTRUCTION_SETS,
58
- available_tools: Agent::AVAILABLE_TOOLS
69
+ available_tools: Agent.available_tools
59
70
  }
60
71
  }
61
72
  end
@@ -65,7 +76,7 @@ module ActionAgent
65
76
  render json: {
66
77
  agent: agent_json(@agent, include_details: true),
67
78
  versions: @agent.agent_versions.recent.limit(10).map { |v| version_json(v) },
68
- recent_runs: @agent.agent_runs.recent.limit(5).map(&:summary)
79
+ recent_runs: @agent.agent_runs.with_attachments.recent.limit(5).map(&:summary)
69
80
  }
70
81
  end
71
82
 
@@ -134,7 +145,7 @@ module ActionAgent
134
145
  # One digest->version map for the page; labels each run's instructions
135
146
  # with the agent version that introduced them where one matches.
136
147
  digest_versions = @agent.instructions_digest_versions
137
- runs_by_id = AgentRun.where(id: executions[:rows].select { |r| r.source == "dashboard" }.map(&:id))
148
+ runs_by_id = AgentRun.with_attachments.where(id: executions[:rows].select { |r| r.source == "dashboard" }.map(&:id))
138
149
  .index_by(&:id)
139
150
 
140
151
  render json: {
@@ -150,11 +161,16 @@ module ActionAgent
150
161
  end
151
162
 
152
163
  # POST /api/agents/:id/execute
164
+ #
165
+ # JSON as before, or multipart from the runner's composer: the new
166
+ # user message, its files (attachments[]) and the conversation to
167
+ # continue (params[context_id], or a top-level context_id).
153
168
  def execute
154
169
  run = @agent.execute(
155
- params[:prompt],
170
+ execution_prompt,
156
171
  action: params[:action_name],
157
- **params.fetch(:params, {}).to_unsafe_h.symbolize_keys
172
+ attachments: uploaded_attachments,
173
+ **execution_params
158
174
  )
159
175
  record_execution_usage
160
176
 
@@ -164,15 +180,54 @@ module ActionAgent
164
180
  # POST /api/agents/:id/test
165
181
  def test
166
182
  run = @agent.test_execute(
167
- params[:prompt],
183
+ execution_prompt,
168
184
  action: params[:action_name],
169
- **params.fetch(:params, {}).to_unsafe_h.symbolize_keys
185
+ attachments: uploaded_attachments,
186
+ **execution_params
170
187
  )
171
188
  record_execution_usage
172
189
 
173
190
  render json: { run: run.summary, output: run.output }
174
191
  end
175
192
 
193
+ # GET /api/agents/:id/conversations
194
+ #
195
+ # The agent's persisted contexts, newest first — the runner's
196
+ # conversation picker, narrowed to one action when asked.
197
+ def conversations
198
+ limit = params.fetch(:limit, CONVERSATIONS_LIMIT).to_i.clamp(1, 200)
199
+ contexts = agent_contexts.order(created_at: :desc)
200
+ contexts = contexts.for_action(params[:action_name]) if params[:action_name].present?
201
+ # The picker is refetched on every seeded or deleted message, and the
202
+ # list grows a row per New conversation, so it is bounded like every
203
+ # other collection this API serves.
204
+ contexts = contexts.limit(limit).to_a
205
+ counts = AgentMessage.where(agent_context_id: contexts.map(&:id)).group(:agent_context_id).count
206
+
207
+ render json: {
208
+ conversations: contexts.map { |context| conversation_json(context, counts[context.id] || 0) }
209
+ }
210
+ end
211
+
212
+ # POST /api/agents/:id/conversations
213
+ #
214
+ # Starts an empty context for an action, so the runner's very first
215
+ # message already lands in a pinned conversation rather than in the
216
+ # agent's default stream.
217
+ def create_conversation
218
+ action = params[:action_name].presence || Agent::DEFAULT_ACTION
219
+ action = Agent::DEFAULT_ACTION unless @agent.available_actions.include?(action)
220
+
221
+ context = AgentContext.create!(
222
+ contextable: @agent,
223
+ agent_name: @agent.telemetry_agent_class,
224
+ action_name: action,
225
+ instructions: @agent.composed_instructions_for(action)
226
+ )
227
+
228
+ render json: { conversation: conversation_json(context, 0) }, status: :created
229
+ end
230
+
176
231
  # POST /api/agents/:id/duplicate
177
232
  def duplicate
178
233
  new_agent = @agent.dup
@@ -326,6 +381,58 @@ module ActionAgent
326
381
  }, status: :unprocessable_entity
327
382
  end
328
383
 
384
+ # A message may be empty only when files carry the request.
385
+ def require_prompt!
386
+ return if params[:prompt].present? || uploaded_attachments.any?
387
+
388
+ render json: { error: "Prompt can't be blank unless files are attached" }, status: :unprocessable_entity
389
+ end
390
+
391
+ def execution_prompt
392
+ params[:prompt].presence || "(see attached files)"
393
+ end
394
+
395
+ # Multipart files only: a JSON body can't carry one, and an empty file
396
+ # input arrives as a blank string.
397
+ def uploaded_attachments
398
+ @uploaded_attachments ||= Array(params[:attachments]).select { |file| file.respond_to?(:original_filename) }
399
+ end
400
+
401
+ # Per-run overrides (provider/model overrides, the pinned context)
402
+ # for input_params. context_id is stored as an integer so the JSON
403
+ # column reads the same whether a form or a JSON body delivered it;
404
+ # anything that isn't one is dropped rather than pinned to nothing.
405
+ def execution_params
406
+ extra = params.fetch(:params, {}).to_unsafe_h.symbolize_keys
407
+ # These reach Agent#execute as keywords, and a keyword splat wins over
408
+ # the arguments before it: left in, params[params][attachments] would
409
+ # replace the uploaded files with anything the caller names, and
410
+ # params[params][action] the action. They are the controller's to set.
411
+ extra.except!(*RESERVED_EXECUTION_KEYS)
412
+ context_id = Integer(extra.delete(:context_id).presence || params[:context_id].presence || 0, exception: false)
413
+ extra[:context_id] = context_id if context_id&.positive?
414
+ extra
415
+ end
416
+
417
+ def attachments_unavailable(exception)
418
+ render json: { error: exception.message }, status: :unprocessable_entity
419
+ end
420
+
421
+ def agent_contexts
422
+ AgentContext.where(contextable: @agent)
423
+ end
424
+
425
+ def conversation_json(context, message_count)
426
+ {
427
+ id: context.id,
428
+ action_name: context.action_name,
429
+ agent_name: context.agent_name,
430
+ message_count: message_count,
431
+ last_activity_at: context.updated_at.iso8601,
432
+ created_at: context.created_at.iso8601
433
+ }
434
+ end
435
+
329
436
  def list_sort(requested)
330
437
  LIST_SORTS.key?(requested.to_s) ? requested.to_s : DEFAULT_LIST_SORT
331
438
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ class DashboardAssistantController < BaseController
6
+ protect_from_forgery with: :exception
7
+
8
+ before_action :require_assistant_enabled!
9
+ before_action :require_owner!
10
+ before_action :require_execution_enabled!, only: :create
11
+ before_action :enforce_execution_quota!, only: :create
12
+
13
+ rescue_from DashboardAssistantService::InvalidInput, with: :invalid_input
14
+ rescue_from DashboardAssistantService::ProcessingConsentRequired, with: :processing_consent_required
15
+ rescue_from DashboardAssistantService::SetupRequired, with: :setup_required
16
+ # Rails 8.2 verifies forgery protection from the browser's Sec-Fetch-Site
17
+ # header, renamed the failure to InvalidCrossOriginRequest, and deprecated
18
+ # the old name. Rescue whichever names the running Rails defines, so a
19
+ # rejected request answers with the dashboard's JSON either way.
20
+ # const_defined? does not fire the deprecation the bare constant would.
21
+ rescue_from ActionController::InvalidCrossOriginRequest, with: :invalid_authenticity_token
22
+ if ActionController.const_defined?(:InvalidAuthenticityToken, false)
23
+ rescue_from ActionController::InvalidAuthenticityToken, with: :invalid_authenticity_token
24
+ end
25
+
26
+ def show
27
+ render json: DashboardAssistantService.new(owner: current_owner).configuration
28
+ end
29
+
30
+ def create
31
+ input = params.to_unsafe_h.symbolize_keys.slice(:message, :history, :provider, :model, :allow_provider_processing)
32
+ assistant = DashboardAssistantService.new(owner: current_owner, **input)
33
+ assistant.validate!
34
+ record_execution_usage
35
+ render json: assistant.call
36
+ rescue StandardError => exception
37
+ raise if exception.is_a?(DashboardAssistantService::InvalidInput) ||
38
+ exception.is_a?(DashboardAssistantService::ProcessingConsentRequired) ||
39
+ exception.is_a?(DashboardAssistantService::SetupRequired) ||
40
+ exception.is_a?(ActiveRecord::Encryption::Errors::Configuration)
41
+
42
+ # Provider exceptions can contain credentials or raw request bodies.
43
+ Rails.logger.warn("[DashboardAssistant] Generation failed (#{exception.class.name})")
44
+ render json: { error: "The assistant could not complete this request. Retry, or ask the dashboard administrator to check the server logs.", code: "generation_failed" }, status: :bad_gateway
45
+ end
46
+
47
+ private
48
+
49
+ # The assistant ships as a development and CI tool (see
50
+ # ActionAgent.assistant_enabled). Where it is off, it is not a view a
51
+ # caller can reach by knowing the route: the dashboard omits it, and
52
+ # both endpoints refuse.
53
+ def require_assistant_enabled!
54
+ return if ActionAgent.assistant_enabled?
55
+
56
+ render json: {
57
+ error: "The dashboard assistant is available in development and test. " \
58
+ "Set ActionAgent.assistant_enabled = true to enable it in this environment.",
59
+ code: "assistant_disabled"
60
+ }, status: :forbidden
61
+ end
62
+
63
+ def invalid_input(exception)
64
+ render json: { error: exception.message, code: "invalid_input" }, status: :unprocessable_entity
65
+ end
66
+
67
+ def processing_consent_required(exception)
68
+ render json: { error: exception.message, code: "processing_consent_required" }, status: :unprocessable_entity
69
+ end
70
+
71
+ def setup_required(exception)
72
+ render json: {
73
+ error: exception.message, code: "setup_required", setup_required: true,
74
+ action: { type: "open_settings", path: "/settings", label: "Configure provider" }
75
+ }, status: :service_unavailable
76
+ end
77
+
78
+ def invalid_authenticity_token
79
+ render json: { error: "Refresh the dashboard before sending another message", code: "invalid_csrf_token" }, status: :unprocessable_entity
80
+ end
81
+ end
82
+ end
83
+ end
@@ -10,6 +10,10 @@ module ActionAgent
10
10
  # than sampling recorded generations, and can be narrowed to a group, to
11
11
  # specific scenarios, or to specific models.
12
12
  class EvaluationsController < BaseController
13
+ rescue_from ActiveAgent::Evals::ScenarioParser::ParseError do |error|
14
+ render json: { errors: [ error.message ] }, status: :unprocessable_entity
15
+ end
16
+
13
17
  before_action :require_owner!
14
18
  # A scenario suite replays its prompts through the provider, so creating
15
19
  # one that runs, or running one, executes the agent and is gated the way
@@ -237,11 +241,15 @@ module ActionAgent
237
241
  end
238
242
  end
239
243
 
240
- # The refusal AgentsController gives an observed agent: it was
241
- # discovered from telemetry and has nothing to execute.
244
+ # Observed agents cannot use the engine's execution service. A persisted
245
+ # evaluation with an explicit host adapter runs in that source instead.
242
246
  def require_executable_scenario_agent!
243
247
  agent = action_name == "create" ? requested_agent : current_evaluation.agent
244
248
  return unless agent.observed?
249
+ if action_name == "run"
250
+ adapter = ActionAgent.scenario_evaluation_adapter_resolver&.call(current_evaluation)
251
+ return if adapter.respond_to?(:call)
252
+ end
245
253
 
246
254
  render json: {
247
255
  error: "Observed agents are read-only — duplicate this agent to create an executable copy"
@@ -271,22 +279,29 @@ module ActionAgent
271
279
  }.compact_blank
272
280
  end
273
281
 
274
- # Scenarios from the request: a pasted text block, a list of objects, or
275
- # nothing (a generation-sampling evaluation).
282
+ # Scenarios from text, a YAML/JSON suite, or a list of objects. Production
283
+ # questions are selected at import time, only on explicit opt-in; the
284
+ # persisted scenario records do not store an environment flag.
276
285
  def scenario_attributes
277
286
  @scenario_attributes ||= begin
278
287
  source = params[:evaluation].presence || params
279
288
  text = source[:scenarios_text].to_s
280
289
  list = source[:scenarios]
290
+ include_production_only = ActiveModel::Type::Boolean.new.cast(source[:include_production_only]) == true
281
291
 
282
- if list.present?
292
+ imported = if list.present?
283
293
  list = list.to_unsafe_h.values if list.is_a?(ActionController::Parameters)
284
- ActiveAgent::Evals::ScenarioParser.parse(Array(list).map { |entry| entry.respond_to?(:to_unsafe_h) ? entry.to_unsafe_h : entry }.to_json)
294
+ serialized = Array(list).map { |entry| entry.respond_to?(:to_unsafe_h) ? entry.to_unsafe_h : entry }.to_json
295
+ ActiveAgent::Evals::ScenarioParser.parse(serialized, include_production_only: include_production_only)
285
296
  elsif text.present?
286
- ActiveAgent::Evals::ScenarioParser.parse(text)
297
+ ActiveAgent::Evals::ScenarioParser.parse(text, include_production_only: include_production_only)
287
298
  else
288
299
  []
289
300
  end
301
+ if (list.present? || text.present?) && imported.empty?
302
+ raise ActiveAgent::Evals::ScenarioParser::ParseError, "No scenarios matched the import. Check the catalog or include_production_only selection."
303
+ end
304
+ imported
290
305
  end
291
306
  end
292
307
 
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Edits to a conversation context from the agent runner: seed a user or
6
+ # assistant turn without running the agent, fix a turn's text, or drop
7
+ # one — so the next run sees exactly the history the tester intends.
8
+ #
9
+ # Only user and assistant turns are editable. Tool and system rows are
10
+ # written by execution and read back as call/result pairs; half a pair
11
+ # is worse than an uneditable one.
12
+ class InteractionMessagesController < BaseController
13
+ EDITABLE_ROLES = %w[user assistant].freeze
14
+
15
+ # Marks turns typed into the context by hand, as opposed to the
16
+ # provenance SolidAgent stamps on turns a run produced.
17
+ MANUAL_PROVENANCE = { "source" => "dashboard", "manual" => true }.freeze
18
+
19
+ rescue_from Agent::ObservedAgentError, with: :observed_agent_read_only
20
+
21
+ before_action :require_owner!
22
+ before_action :set_context
23
+ before_action :require_executable_agent!, only: [ :create, :update, :destroy ]
24
+ before_action :set_message, only: [ :update, :destroy ]
25
+
26
+ # POST /api/interactions/:interaction_id/messages
27
+ def create
28
+ role = params[:role].to_s
29
+ unless EDITABLE_ROLES.include?(role)
30
+ return render json: { error: "role must be user or assistant" }, status: :unprocessable_entity
31
+ end
32
+
33
+ content = params[:content].to_s
34
+ return render json: { error: "content can't be blank" }, status: :unprocessable_entity if content.blank?
35
+
36
+ message = @context.messages.create!(
37
+ role: role,
38
+ content: content,
39
+ content_checksum: Digest::MD5.hexdigest(content),
40
+ provenance: MANUAL_PROVENANCE
41
+ )
42
+ @context.touch
43
+
44
+ render json: { message: AgentMessageSerializer.call(message) }, status: :created
45
+ end
46
+
47
+ # PATCH /api/interactions/:interaction_id/messages/:id
48
+ def update
49
+ content = params[:content].to_s
50
+ return render json: { error: "content can't be blank" }, status: :unprocessable_entity if content.blank?
51
+
52
+ @message.update!(content: content, content_checksum: Digest::MD5.hexdigest(content))
53
+ @context.touch
54
+
55
+ render json: { message: AgentMessageSerializer.call(@message) }
56
+ end
57
+
58
+ # DELETE /api/interactions/:interaction_id/messages/:id
59
+ def destroy
60
+ @message.destroy!
61
+ @context.touch
62
+
63
+ head :no_content
64
+ end
65
+
66
+ private
67
+
68
+ def set_context
69
+ @context = AgentContext.for_agents(owner_agents).find(params[:interaction_id])
70
+ end
71
+
72
+ # Seeding, fixing or dropping a turn writes the agent's own history, so
73
+ # it answers to the same read-only policy execution does (#414): an
74
+ # observed agent is a mirror of someone else's telemetry, and a turn
75
+ # typed in here would be a fabrication attributed to it. Asked of the
76
+ # agent rather than re-tested here, so `observed` has one definition and
77
+ # one message. Reads are left open — every action this controller has is
78
+ # a write; the conversation itself is still listed and shown.
79
+ def require_executable_agent!
80
+ agent = @context.contextable
81
+ agent.ensure_executable! if agent.respond_to?(:ensure_executable!)
82
+ end
83
+
84
+ def observed_agent_read_only(exception)
85
+ render json: { error: exception.message }, status: :unprocessable_entity
86
+ end
87
+
88
+ # Looked up through the context, so a message id from another
89
+ # conversation is a 404 rather than a cross-conversation edit.
90
+ def set_message
91
+ @message = @context.messages.find(params[:id])
92
+ return if EDITABLE_ROLES.include?(@message.role)
93
+
94
+ render json: { error: "Only user and assistant messages can be edited" }, status: :unprocessable_entity
95
+ end
96
+ end
97
+ end
98
+ end
@@ -41,8 +41,9 @@ 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
+ assistantEnabled: ActionAgent.assistant_enabled?,
46
47
  multiTenant: ActionAgent.multi_tenant?,
47
48
  upgradeUrl: ActionAgent.upgrade_url,
48
49
  signOutPath: ActionAgent.sign_out_path
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ActionAgent
4
4
  class Agent < ApplicationRecord
5
+ class ObservedAgentError < StandardError; end
6
+
5
7
  include Ownable
6
8
  owned_by :user, :account
7
9
 
@@ -39,6 +41,7 @@ module ActionAgent
39
41
 
40
42
  # Callbacks
41
43
  before_validation :generate_slug, on: :create
44
+ before_validation :apply_conventional_schema_tools, on: :create
42
45
  after_create :create_initial_version
43
46
  after_update :create_version_on_config_change, if: :configuration_changed?
44
47
 
@@ -70,17 +73,62 @@ module ActionAgent
70
73
  github ruby rails aws gcp python typescript docker kubernetes
71
74
  ].freeze
72
75
 
73
- # 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.
74
80
  AVAILABLE_TOOLS = %w[
75
- terminal playwright filesystem code database slack fetch search edit translate memory agents
81
+ terminal playwright filesystem code database slack fetch search edit translate memory agents ui
76
82
  ].freeze
77
83
 
84
+ # Every tool an agent may enable: the built-ins plus each tool generated by
85
+ # the host's declared ActiveAgent::SchemaTools classes (ActionAgent.schema_tools).
86
+ #
87
+ # Computed per call, never memoized: in development the host's tool classes
88
+ # are autoloaded and reloaded, so a cached list would either miss them at
89
+ # boot or go stale after a reload.
90
+ # @return [Array<String>]
91
+ def self.available_tools
92
+ AVAILABLE_TOOLS | ActionAgent.schema_tool_names
93
+ end
94
+
78
95
  # Available providers
79
96
  PROVIDERS = %w[openai anthropic ollama openrouter].freeze
80
97
 
81
98
  # The ActiveAgent class name this agent's runs are recorded under — the
82
99
  # correlation key between platform Agent records and telemetry traces
83
100
  # (TelemetryTrace#agent_class) and solid_agent contexts.
101
+ # Tools a schema tool class claims for this agent by naming convention:
102
+ # Reservation -> ReservationTools -> ReservationAgent.
103
+ #
104
+ # This is a DEFAULT SELECTION, never a restriction. Any agent may enable
105
+ # any tool in .available_tools; the convention only decides what a newly
106
+ # created ReservationAgent starts with.
107
+ # @return [Array<String>]
108
+ def conventional_schema_tools
109
+ # Compared on letters only. `telemetry_agent_class` is not reliable here:
110
+ # it runs `parameterize.camelize`, which turns an already-camelised
111
+ # "TicketAgent" into "Ticketagent" and matches nothing, while
112
+ # "Milestone Agent" happens to survive. Normalising both sides makes
113
+ # "TicketAgent", "Ticket Agent" and "ticket_agent" all match.
114
+ identifier = (agent_class_name.presence || name.to_s).gsub(/[^a-z]/i, "").downcase
115
+ return [] if identifier.blank?
116
+
117
+ ActionAgent.schema_tool_classes.select do |klass|
118
+ "#{klass.model.name}Agent".downcase == identifier
119
+ end.flat_map(&:tool_names).map(&:to_s)
120
+ end
121
+
122
+ # Seeds a new agent named after a model with that model's tools. Only on
123
+ # create, and only when none were chosen — a deliberate selection, empty
124
+ # included, is never overwritten, and the editor can deselect afterwards.
125
+ def apply_conventional_schema_tools
126
+ return if tools.present?
127
+
128
+ defaults = conventional_schema_tools
129
+ self.tools = defaults if defaults.any?
130
+ end
131
+
84
132
  def telemetry_agent_class
85
133
  base = agent_class_name.presence || name.parameterize(separator: "_").camelize
86
134
  base.end_with?("Agent") ? base : "#{base}Agent"
@@ -201,15 +249,14 @@ module ActionAgent
201
249
  RUBY
202
250
  end
203
251
 
204
- # Execute a run with this agent
205
- def execute(input_prompt, action: nil, **params)
206
- run = agent_runs.create!(
207
- input_prompt: input_prompt,
208
- action_name: normalized_action(action),
209
- input_params: params,
210
- status: :pending,
211
- trace_id: SecureRandom.uuid
212
- )
252
+ # Execute a run with this agent. Files in +attachments+ (uploaded files,
253
+ # {io:, filename:, content_type:} hashes or blobs) are stored on the run
254
+ # before the job is enqueued, so a worker on another machine finds them
255
+ # attached. +params+ (provider/model overrides, the context_id of a
256
+ # conversation to continue) are kept on the run as input_params.
257
+ def execute(input_prompt, action: nil, attachments: [], **params)
258
+ ensure_executable!
259
+ run = create_run(input_prompt, action: action, attachments: attachments, params: params, status: :pending)
213
260
 
214
261
  # Queue the execution job
215
262
  AgentExecutionJob.perform_later(run.id)
@@ -218,14 +265,11 @@ module ActionAgent
218
265
  end
219
266
 
220
267
  # Quick test execution (synchronous)
221
- def test_execute(input_prompt, action: nil, **params)
222
- run = agent_runs.create!(
223
- input_prompt: input_prompt,
224
- action_name: normalized_action(action),
225
- input_params: params,
226
- status: :running,
227
- trace_id: SecureRandom.uuid,
228
- started_at: Time.current
268
+ def test_execute(input_prompt, action: nil, attachments: [], **params)
269
+ ensure_executable!
270
+ run = create_run(
271
+ input_prompt, action: action, attachments: attachments, params: params,
272
+ status: :running, started_at: Time.current
229
273
  )
230
274
 
231
275
  begin
@@ -254,8 +298,52 @@ module ActionAgent
254
298
  run
255
299
  end
256
300
 
301
+ # An observed agent was reconstructed from telemetry, so it is read-only:
302
+ # editing it would rewrite a record of what ran. Executing it is a
303
+ # different question — it carries the instructions, model and MCP servers a
304
+ # run needs, and evaluating the agent that actually served production is
305
+ # the case operators ask for. So a run is allowed once the agent names a
306
+ # server the dashboard can reach, and refused when it would have nothing to
307
+ # call.
308
+ def ensure_executable!
309
+ return unless observed?
310
+ return if MCPToolDispatcher.new(self).any_reachable_server?
311
+
312
+ raise ObservedAgentError,
313
+ "This agent was observed from telemetry and is read-only: it names no reachable MCP server — duplicate it to create an executable copy"
314
+ end
315
+
257
316
  private
258
317
 
318
+ # Refuses files before creating anything: a run that exists but lost
319
+ # its attachments would execute against the wrong prompt.
320
+ def create_run(input_prompt, action:, attachments:, params:, **attributes)
321
+ files = Array.wrap(attachments).compact
322
+ raise AgentRun::AttachmentsUnavailable if files.any? && !AgentRun.attachments_available?
323
+
324
+ run = agent_runs.create!(
325
+ input_prompt: input_prompt,
326
+ action_name: normalized_action(action),
327
+ input_params: params,
328
+ trace_id: SecureRandom.uuid,
329
+ **attributes
330
+ )
331
+
332
+ if files.any?
333
+ begin
334
+ run.attachments.attach(*files)
335
+ rescue StandardError
336
+ # An attach that raises (an unwritable service, a value that is not
337
+ # a file) happens after the row exists, and the caller never reaches
338
+ # the enqueue: without this the run would sit pending forever.
339
+ run.destroy
340
+ raise
341
+ end
342
+ end
343
+
344
+ run
345
+ end
346
+
259
347
  def slug_unique_within_owner
260
348
  return if slug.blank?
261
349