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.
- 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 +117 -10
- 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 +2 -1
- data/app/models/action_agent/agent.rb +107 -19
- 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 +69 -5
- 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 +148 -0
- data/app/services/action_agent/playwright_mcp_client.rb +11 -126
- data/app/services/action_agent/scenario_evaluation_runner.rb +90 -17
- 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 +113 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
-
#
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|