actionagent 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +70 -48
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +116 -9
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +22 -7
- data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
- data/app/controllers/action_agent/dashboard_controller.rb +1 -0
- data/app/models/action_agent/agent.rb +60 -18
- data/app/models/action_agent/agent_run.rb +99 -0
- data/app/models/action_agent/evaluation_run.rb +14 -6
- data/app/models/action_agent/evaluation_scenario_result.rb +24 -4
- data/app/models/action_agent/telemetry_trace.rb +16 -1
- data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
- data/app/services/action_agent/agent_execution_service.rb +294 -16
- data/app/services/action_agent/agent_registrar.rb +7 -6
- data/app/services/action_agent/agent_toolbox.rb +45 -3
- data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
- data/app/services/action_agent/evaluation_evidence.rb +234 -0
- data/app/services/action_agent/evaluation_runner_service.rb +13 -3
- data/app/services/action_agent/evaluation_tool_resolver.rb +10 -2
- data/app/services/action_agent/mcp_client.rb +167 -0
- data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
- data/app/services/action_agent/playwright_mcp_client.rb +11 -126
- data/app/services/action_agent/scenario_evaluation_runner.rb +49 -15
- data/config/routes.rb +11 -1
- data/lib/action_agent/assistant_request_filter.rb +22 -0
- data/lib/action_agent/engine.rb +5 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +32 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
- metadata +10 -6
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
170
|
+
execution_prompt,
|
|
156
171
|
action: params[:action_name],
|
|
157
|
-
|
|
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
|
-
|
|
183
|
+
execution_prompt,
|
|
168
184
|
action: params[:action_name],
|
|
169
|
-
|
|
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
|
-
#
|
|
241
|
-
#
|
|
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
|
|
275
|
-
#
|
|
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
|
-
|
|
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
|
|
@@ -43,6 +43,7 @@ module ActionAgent
|
|
|
43
43
|
instructionSets: Agent::INSTRUCTION_SETS,
|
|
44
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
|
|
|
@@ -72,7 +74,7 @@ module ActionAgent
|
|
|
72
74
|
|
|
73
75
|
# Available tools/MCPs
|
|
74
76
|
AVAILABLE_TOOLS = %w[
|
|
75
|
-
terminal playwright filesystem code database slack fetch search edit translate memory agents
|
|
77
|
+
terminal playwright filesystem code database slack fetch search edit translate memory agents ui
|
|
76
78
|
].freeze
|
|
77
79
|
|
|
78
80
|
# Available providers
|
|
@@ -201,15 +203,14 @@ module ActionAgent
|
|
|
201
203
|
RUBY
|
|
202
204
|
end
|
|
203
205
|
|
|
204
|
-
# Execute a run with this agent
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
)
|
|
206
|
+
# Execute a run with this agent. Files in +attachments+ (uploaded files,
|
|
207
|
+
# {io:, filename:, content_type:} hashes or blobs) are stored on the run
|
|
208
|
+
# before the job is enqueued, so a worker on another machine finds them
|
|
209
|
+
# attached. +params+ (provider/model overrides, the context_id of a
|
|
210
|
+
# conversation to continue) are kept on the run as input_params.
|
|
211
|
+
def execute(input_prompt, action: nil, attachments: [], **params)
|
|
212
|
+
ensure_executable!
|
|
213
|
+
run = create_run(input_prompt, action: action, attachments: attachments, params: params, status: :pending)
|
|
213
214
|
|
|
214
215
|
# Queue the execution job
|
|
215
216
|
AgentExecutionJob.perform_later(run.id)
|
|
@@ -218,14 +219,11 @@ module ActionAgent
|
|
|
218
219
|
end
|
|
219
220
|
|
|
220
221
|
# Quick test execution (synchronous)
|
|
221
|
-
def test_execute(input_prompt, action: nil, **params)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
status: :running,
|
|
227
|
-
trace_id: SecureRandom.uuid,
|
|
228
|
-
started_at: Time.current
|
|
222
|
+
def test_execute(input_prompt, action: nil, attachments: [], **params)
|
|
223
|
+
ensure_executable!
|
|
224
|
+
run = create_run(
|
|
225
|
+
input_prompt, action: action, attachments: attachments, params: params,
|
|
226
|
+
status: :running, started_at: Time.current
|
|
229
227
|
)
|
|
230
228
|
|
|
231
229
|
begin
|
|
@@ -254,8 +252,52 @@ module ActionAgent
|
|
|
254
252
|
run
|
|
255
253
|
end
|
|
256
254
|
|
|
255
|
+
# An observed agent was reconstructed from telemetry, so it is read-only:
|
|
256
|
+
# editing it would rewrite a record of what ran. Executing it is a
|
|
257
|
+
# different question — it carries the instructions, model and MCP servers a
|
|
258
|
+
# run needs, and evaluating the agent that actually served production is
|
|
259
|
+
# the case operators ask for. So a run is allowed once the agent names a
|
|
260
|
+
# server the dashboard can reach, and refused when it would have nothing to
|
|
261
|
+
# call.
|
|
262
|
+
def ensure_executable!
|
|
263
|
+
return unless observed?
|
|
264
|
+
return if MCPToolDispatcher.new(self).any_reachable_server?
|
|
265
|
+
|
|
266
|
+
raise ObservedAgentError,
|
|
267
|
+
"This agent was observed from telemetry and is read-only: it names no reachable MCP server — duplicate it to create an executable copy"
|
|
268
|
+
end
|
|
269
|
+
|
|
257
270
|
private
|
|
258
271
|
|
|
272
|
+
# Refuses files before creating anything: a run that exists but lost
|
|
273
|
+
# its attachments would execute against the wrong prompt.
|
|
274
|
+
def create_run(input_prompt, action:, attachments:, params:, **attributes)
|
|
275
|
+
files = Array.wrap(attachments).compact
|
|
276
|
+
raise AgentRun::AttachmentsUnavailable if files.any? && !AgentRun.attachments_available?
|
|
277
|
+
|
|
278
|
+
run = agent_runs.create!(
|
|
279
|
+
input_prompt: input_prompt,
|
|
280
|
+
action_name: normalized_action(action),
|
|
281
|
+
input_params: params,
|
|
282
|
+
trace_id: SecureRandom.uuid,
|
|
283
|
+
**attributes
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if files.any?
|
|
287
|
+
begin
|
|
288
|
+
run.attachments.attach(*files)
|
|
289
|
+
rescue StandardError
|
|
290
|
+
# An attach that raises (an unwritable service, a value that is not
|
|
291
|
+
# a file) happens after the row exists, and the caller never reaches
|
|
292
|
+
# the enqueue: without this the run would sit pending forever.
|
|
293
|
+
run.destroy
|
|
294
|
+
raise
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
run
|
|
299
|
+
end
|
|
300
|
+
|
|
259
301
|
def slug_unique_within_owner
|
|
260
302
|
return if slug.blank?
|
|
261
303
|
|