actionagent 1.8.1 → 1.9.1
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 +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/feedback_ingest_controller.rb +34 -0
- data/app/controllers/action_agent/api/feedbacks_controller.rb +40 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +26 -2
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +39 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +16 -4
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +84 -0
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/interaction_feedback.rb +24 -0
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +200 -3
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +297 -14
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_runner_service.rb +4 -5
- data/app/services/action_agent/evaluation_standing.rb +15 -8
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +155 -1
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +507 -22
- data/lib/generators/action_agent/install_generator.rb +37 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb +17 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- metadata +100 -2
|
@@ -45,14 +45,21 @@ module ActionAgent
|
|
|
45
45
|
HISTORY_TURN_LIMIT = 40
|
|
46
46
|
HISTORY_CHAR_BUDGET = 60_000
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
# +resume+ continues a run that paused for input (AgentResumeJob):
|
|
49
|
+
#
|
|
50
|
+
# checkpoint the checkpoint the pause stored
|
|
51
|
+
# answers an answer per paused tool call id; false declines
|
|
52
|
+
# secrets the pause's `:secret` answers, scrubbed from everything
|
|
53
|
+
# this execution records
|
|
54
|
+
def self.call(agent_record, run, resume: nil)
|
|
55
|
+
new(agent_record, run, resume: resume).call
|
|
50
56
|
end
|
|
51
57
|
|
|
52
|
-
# Returns the providers in Agent::PROVIDERS a run on +owner+'s behalf
|
|
53
|
-
# credentials for, in that order (see
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
# Returns the providers in Agent::PROVIDERS a run on +owner+'s behalf,
|
|
59
|
+
# started by +actor+, has credentials for, in that order (see
|
|
60
|
+
# #available_providers).
|
|
61
|
+
def self.available_providers(owner, actor: nil)
|
|
62
|
+
new(nil, nil, owner: owner, actor: actor).available_providers
|
|
56
63
|
end
|
|
57
64
|
|
|
58
65
|
# Tool-call keywords that name the caller. The model's arguments and the
|
|
@@ -63,20 +70,35 @@ module ActionAgent
|
|
|
63
70
|
ACTOR_KEYWORDS = %i[actor current_user].freeze
|
|
64
71
|
|
|
65
72
|
# +owner+ is whose provider credentials the run uses: the agent record's
|
|
66
|
-
# owner unless given.
|
|
67
|
-
|
|
73
|
+
# owner unless given. +actor+ is who those credentials are resolved for
|
|
74
|
+
# (see ProviderCredentials): the run's actor unless given. +resume+ carries
|
|
75
|
+
# a paused run's checkpoint and the secret answers it was given.
|
|
76
|
+
def initialize(agent_record, run, owner: nil, actor: nil, resume: nil)
|
|
68
77
|
@agent_record = agent_record
|
|
69
78
|
@run = run
|
|
70
79
|
@owner = owner
|
|
80
|
+
@credentials_actor = actor
|
|
81
|
+
@resume = resume
|
|
82
|
+
@secrets = Array(resume&.dig(:secrets)).map(&:to_s)
|
|
71
83
|
@tool_invocations = []
|
|
72
|
-
@event_sequence = 0
|
|
84
|
+
@event_sequence = resume ? recorded_event_count : 0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Whether this execution continues a run that paused for input.
|
|
88
|
+
def resuming?
|
|
89
|
+
!@resume.nil?
|
|
73
90
|
end
|
|
74
91
|
|
|
75
92
|
# Returns the providers in Agent::PROVIDERS the owner's credentials, or
|
|
76
|
-
# the host's config, let a run use: #provider_available? for each.
|
|
93
|
+
# the host's config, let a run use: #provider_available? for each. A
|
|
94
|
+
# provider whose credentials cannot be resolved is left out.
|
|
77
95
|
# @return [Array<String>]
|
|
78
96
|
def available_providers
|
|
79
|
-
Agent::PROVIDERS.select
|
|
97
|
+
Agent::PROVIDERS.select do |name|
|
|
98
|
+
provider_available?(name)
|
|
99
|
+
rescue ProviderCredentials::Unresolved
|
|
100
|
+
false
|
|
101
|
+
end
|
|
80
102
|
end
|
|
81
103
|
|
|
82
104
|
# The caller this run executes on behalf of, or nil when it runs
|
|
@@ -90,6 +112,7 @@ module ActionAgent
|
|
|
90
112
|
# Emits a progress event on the run (streamed to the UI by pollers).
|
|
91
113
|
# Never lets telemetry break execution.
|
|
92
114
|
def emit_event(**kwargs)
|
|
115
|
+
kwargs[:detail] = scrub_secrets(kwargs[:detail]) if kwargs.key?(:detail)
|
|
93
116
|
@run.append_event(**kwargs)
|
|
94
117
|
rescue StandardError => e
|
|
95
118
|
Rails.logger.warn("[AgentExecutionService] event emit failed: #{e.message}")
|
|
@@ -115,7 +138,9 @@ module ActionAgent
|
|
|
115
138
|
@agent_record.ensure_executable!
|
|
116
139
|
mcp_dispatcher.ensure_extra_servers_live!
|
|
117
140
|
root_span = @root_span = build_root_span
|
|
118
|
-
|
|
141
|
+
# A resumed segment sends the conversation its pause stored; the prompt
|
|
142
|
+
# span of the run's first segment already shows it.
|
|
143
|
+
record_prompt_span(root_span) unless resuming?
|
|
119
144
|
llm_span = root_span.add_span(
|
|
120
145
|
"llm.generate",
|
|
121
146
|
span_type: :llm,
|
|
@@ -141,13 +166,17 @@ module ActionAgent
|
|
|
141
166
|
duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - llm_started) * 1000).round,
|
|
142
167
|
detail: "#{input} in / #{output} out tokens#{thinking.positive? ? " / #{thinking} thinking" : ""}"
|
|
143
168
|
)
|
|
169
|
+
paused = response.respond_to?(:awaiting_input?) && response.awaiting_input?
|
|
144
170
|
tool_calls = record_tool_spans(root_span, response)
|
|
145
|
-
|
|
171
|
+
# A paused turn's calls have no results yet; the segment that
|
|
172
|
+
# finishes the run persists the whole exchange once.
|
|
173
|
+
persist_tool_messages(response) unless paused
|
|
146
174
|
sync_context_instructions
|
|
175
|
+
root_span.set_attribute("agent.awaiting_input", true) if paused
|
|
147
176
|
root_span.finish
|
|
148
177
|
|
|
149
|
-
{
|
|
150
|
-
output: response.message&.content,
|
|
178
|
+
result = {
|
|
179
|
+
output: paused ? nil : response.message&.content,
|
|
151
180
|
metadata: {
|
|
152
181
|
provider: provider.to_s,
|
|
153
182
|
model: model,
|
|
@@ -156,7 +185,7 @@ module ActionAgent
|
|
|
156
185
|
requested_provider: @agent_record.provider,
|
|
157
186
|
trace_id: root_span.trace_id,
|
|
158
187
|
context_id: conversation_context&.id,
|
|
159
|
-
tool_calls: tool_calls
|
|
188
|
+
tool_calls: earlier_tool_calls + tool_calls
|
|
160
189
|
},
|
|
161
190
|
usage: {
|
|
162
191
|
input_tokens: input,
|
|
@@ -164,6 +193,8 @@ module ActionAgent
|
|
|
164
193
|
total_tokens: usage&.total_tokens || input + output + thinking
|
|
165
194
|
}
|
|
166
195
|
}
|
|
196
|
+
result.merge!(input_requests: response.input_requests, checkpoint: response.checkpoint) if paused
|
|
197
|
+
result
|
|
167
198
|
rescue StandardError => e
|
|
168
199
|
llm_span.record_error(e)
|
|
169
200
|
llm_span.finish
|
|
@@ -173,6 +204,7 @@ module ActionAgent
|
|
|
173
204
|
raise
|
|
174
205
|
ensure
|
|
175
206
|
record_trace(root_span)
|
|
207
|
+
finish_browser_recording
|
|
176
208
|
end
|
|
177
209
|
end
|
|
178
210
|
|
|
@@ -238,12 +270,13 @@ module ActionAgent
|
|
|
238
270
|
# rows, keyed by tool_call_id, and this is a no-op.
|
|
239
271
|
def persist_tool_invocations(context, response)
|
|
240
272
|
return unless context.respond_to?(:add_tool_message)
|
|
241
|
-
return if
|
|
273
|
+
return if response.try(:awaiting_input?)
|
|
274
|
+
return if completed_tool_invocations.empty?
|
|
242
275
|
return if Array(response.respond_to?(:messages) ? response.messages : nil).any? do |message|
|
|
243
276
|
message.respond_to?(:role) && message.role.to_s == "tool"
|
|
244
277
|
end
|
|
245
278
|
|
|
246
|
-
|
|
279
|
+
completed_tool_invocations.each do |invocation|
|
|
247
280
|
context.add_tool_message(
|
|
248
281
|
tool_call_id: nil,
|
|
249
282
|
tool_name: invocation[:name],
|
|
@@ -275,12 +308,19 @@ module ActionAgent
|
|
|
275
308
|
@run_params ||= (@run.input_params || {}).with_indifferent_access
|
|
276
309
|
end
|
|
277
310
|
|
|
311
|
+
# A resumed run keeps the provider and model it paused on: its checkpoint
|
|
312
|
+
# holds that provider's native messages, even if the agent was edited
|
|
313
|
+
# since.
|
|
278
314
|
def requested_provider
|
|
279
|
-
@requested_provider ||= (run_params[:provider_override].presence || @agent_record.provider).to_s
|
|
315
|
+
@requested_provider ||= (paused_metadata["provider"].presence || run_params[:provider_override].presence || @agent_record.provider).to_s
|
|
280
316
|
end
|
|
281
317
|
|
|
282
318
|
def requested_model
|
|
283
|
-
@requested_model ||= run_params[:model_override].presence || @agent_record.model
|
|
319
|
+
@requested_model ||= paused_metadata["model"].presence || run_params[:model_override].presence || @agent_record.model
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def paused_metadata
|
|
323
|
+
resuming? ? @run.output_metadata.to_h : {}
|
|
284
324
|
end
|
|
285
325
|
|
|
286
326
|
# Returns the provider used for this execution, or raises when its
|
|
@@ -316,6 +356,11 @@ module ActionAgent
|
|
|
316
356
|
# Each call is wrapped in a live :tool span (real start/end around the
|
|
317
357
|
# execution) and recorded in @tool_invocations so tool names, arguments
|
|
318
358
|
# and durations reach Traces and the persisted conversation.
|
|
359
|
+
#
|
|
360
|
+
# A call that asks a person returns an ActiveAgent::InputRequest, which
|
|
361
|
+
# pauses the run: the input tools, and any tool on the agent's
|
|
362
|
+
# approval_required_tools until its call is approved. The span and the
|
|
363
|
+
# run's events mark such a call as awaiting rather than done.
|
|
319
364
|
def execute_tool(name, **kwargs)
|
|
320
365
|
forged = kwargs.slice(*ACTOR_KEYWORDS)
|
|
321
366
|
if forged.any?
|
|
@@ -327,11 +372,12 @@ module ActionAgent
|
|
|
327
372
|
# path the model passed — spans/events/persisted args stay unambiguous.
|
|
328
373
|
kwargs[:url] = AgentToolbox.resolve_browse_url(kwargs[:url]) if name.to_s == "browse_page" && kwargs[:url]
|
|
329
374
|
|
|
375
|
+
tool_call_id = ActiveAgent::InputRequest.current_tool_call_id
|
|
330
376
|
span = @root_span&.add_span("tool.#{name}", span_type: :tool)
|
|
331
377
|
span&.set_attribute("tool.name", name.to_s)
|
|
332
378
|
# tool.input.args is the key the Traces UI and TraceInteractionSerializer
|
|
333
379
|
# read — the call's in: side.
|
|
334
|
-
span&.set_attribute("tool.input.args", kwargs.to_json.byteslice(0, 500).to_s.scrub) if kwargs.present?
|
|
380
|
+
span&.set_attribute("tool.input.args", scrub_secrets(kwargs.to_json).byteslice(0, 500).to_s.scrub) if kwargs.present?
|
|
335
381
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
336
382
|
|
|
337
383
|
event_kind = name.to_s == "call_agent" ? "agent" : "tool"
|
|
@@ -340,39 +386,20 @@ module ActionAgent
|
|
|
340
386
|
emit_event(eid: event_id, kind: event_kind, label: event_label, status: "started", detail: kwargs.to_json)
|
|
341
387
|
|
|
342
388
|
result = begin
|
|
343
|
-
|
|
344
|
-
when "save_memory"
|
|
345
|
-
entry = agent_memory.remember(
|
|
346
|
-
kwargs[:content].to_s,
|
|
347
|
-
source_agent: agent_class_name,
|
|
348
|
-
category: kwargs[:category]
|
|
349
|
-
)
|
|
350
|
-
{ saved: true, id: entry.id, content: entry.content }
|
|
351
|
-
when "recall_memory"
|
|
352
|
-
entries = agent_memory.recall(limit: kwargs[:limit], category: kwargs[:category])
|
|
353
|
-
{
|
|
354
|
-
count: entries.size,
|
|
355
|
-
entries: entries.map do |entry|
|
|
356
|
-
{
|
|
357
|
-
content: entry.content,
|
|
358
|
-
category: entry.category,
|
|
359
|
-
source_agent: entry.source_agent,
|
|
360
|
-
created_at: entry.created_at&.iso8601
|
|
361
|
-
}.compact
|
|
362
|
-
end
|
|
363
|
-
}
|
|
364
|
-
when "call_agent"
|
|
365
|
-
call_agent(slug: kwargs[:slug], message: kwargs[:message])
|
|
366
|
-
else
|
|
367
|
-
# A tool one of the agent's own MCP servers serves is called there;
|
|
368
|
-
# AgentToolbox answers the rest.
|
|
369
|
-
# `actor:` comes from the run, never from kwargs (see
|
|
370
|
-
# ACTOR_KEYWORDS): it is who the run is for, not what it is about.
|
|
371
|
-
mcp_dispatcher.call(name, kwargs) || AgentToolbox.call(name, actor: actor, **kwargs)
|
|
372
|
-
end
|
|
389
|
+
approval_request(name, kwargs, tool_call_id) || dispatch_tool(name, kwargs, tool_call_id)
|
|
373
390
|
rescue StandardError => e
|
|
374
|
-
|
|
375
|
-
|
|
391
|
+
message = scrub_secrets(e.message)
|
|
392
|
+
Rails.logger.warn("[AgentExecutionService] Tool #{name} failed: #{e.class} - #{message}")
|
|
393
|
+
{ error: "#{name} failed: #{message}" }
|
|
394
|
+
end
|
|
395
|
+
result = scrub_secrets(result)
|
|
396
|
+
|
|
397
|
+
if result.is_a?(ActiveAgent::InputRequest)
|
|
398
|
+
span&.set_attribute("tool.awaiting_input", true)
|
|
399
|
+
span&.finish
|
|
400
|
+
emit_event(eid: event_id, kind: event_kind, label: event_label, status: "awaiting", detail: result.prompt)
|
|
401
|
+
@tool_invocations << { name: name.to_s, arguments: kwargs, tool_call_id: tool_call_id, awaiting_input: true }
|
|
402
|
+
return result
|
|
376
403
|
end
|
|
377
404
|
|
|
378
405
|
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
|
|
@@ -397,6 +424,7 @@ module ActionAgent
|
|
|
397
424
|
@tool_invocations << {
|
|
398
425
|
name: name.to_s,
|
|
399
426
|
arguments: kwargs,
|
|
427
|
+
tool_call_id: tool_call_id,
|
|
400
428
|
result: result,
|
|
401
429
|
duration_ms: duration_ms,
|
|
402
430
|
error: errored
|
|
@@ -407,6 +435,234 @@ module ActionAgent
|
|
|
407
435
|
|
|
408
436
|
private
|
|
409
437
|
|
|
438
|
+
# -- Browser runs: recording and handoff ---------------------------------
|
|
439
|
+
#
|
|
440
|
+
# An agent with the +playwright_mcp+ tools drives the platform's browser.
|
|
441
|
+
# Its browser tool calls are written to a SessionRecording tied to the run,
|
|
442
|
+
# so Session Replay plays the run back; and when the page asks for
|
|
443
|
+
# something only a person may give, the agent calls +request_handoff+,
|
|
444
|
+
# which stores the page URL and the values entered so far on that
|
|
445
|
+
# recording. Session Replay's Take Over Session then opens that page for
|
|
446
|
+
# the person — the agent registers, the person pays.
|
|
447
|
+
|
|
448
|
+
# At most this many entered values are kept, each clipped to this length.
|
|
449
|
+
HANDOFF_FORM_VALUE_LIMIT = 40
|
|
450
|
+
HANDOFF_FORM_VALUE_LENGTH = 200
|
|
451
|
+
# A value under a key that looks like a secret is never stored: a person
|
|
452
|
+
# checks what the agent filled in, not what the agent must never see.
|
|
453
|
+
HANDOFF_SECRET_KEY = /card|cvc|cvv|expir|password|passcode|secret|token|otp|ssn|iban|account.?number/i
|
|
454
|
+
|
|
455
|
+
def browser_tools_enabled?
|
|
456
|
+
Array(@agent_record.tools).map(&:to_s).include?("playwright_mcp")
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def request_handoff(reason: nil, url: nil, form_values: nil, instructions: nil)
|
|
460
|
+
unless browser_tools_enabled?
|
|
461
|
+
return { error: "request_handoff needs the browser tools (playwright_mcp) enabled on this agent" }
|
|
462
|
+
end
|
|
463
|
+
return { error: "request_handoff needs the url of the page the person continues on" } if url.blank?
|
|
464
|
+
|
|
465
|
+
middleware = browser_recorder
|
|
466
|
+
return { error: "request_handoff could not open the run's session recording" } unless middleware.recording
|
|
467
|
+
|
|
468
|
+
reason = reason.to_s.strip.presence || "a step only a person can take"
|
|
469
|
+
values = handoff_form_values(form_values)
|
|
470
|
+
middleware.capture_for_handoff(url: url.to_s, form_values: values)
|
|
471
|
+
middleware.recording_service.handoff(reason: reason, url: url.to_s, instructions: instructions.presence)
|
|
472
|
+
recording = middleware.recording_service.recording
|
|
473
|
+
@run.add_log("Handoff requested: #{reason} at #{url}")
|
|
474
|
+
|
|
475
|
+
{
|
|
476
|
+
handed_off: true,
|
|
477
|
+
recording_id: recording.id,
|
|
478
|
+
url: url.to_s,
|
|
479
|
+
reason: reason,
|
|
480
|
+
form_values: values,
|
|
481
|
+
message: "Stopped before #{reason}. A person continues from Session Replay → Take Over Session. " \
|
|
482
|
+
"Take no further browser actions; report where you stopped and what you entered."
|
|
483
|
+
}
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def handoff_form_values(values)
|
|
487
|
+
return nil unless values.respond_to?(:to_h)
|
|
488
|
+
|
|
489
|
+
values.to_h.first(HANDOFF_FORM_VALUE_LIMIT).each_with_object({}) do |(key, value), kept|
|
|
490
|
+
next if key.to_s.match?(HANDOFF_SECRET_KEY)
|
|
491
|
+
next unless value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
|
|
492
|
+
|
|
493
|
+
kept[key.to_s] = value.to_s.byteslice(0, HANDOFF_FORM_VALUE_LENGTH).to_s.scrub
|
|
494
|
+
end.presence
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Closes the run's recording when the run recorded anything. A run whose
|
|
498
|
+
# tools never touched the browser has no recording, and gets none here.
|
|
499
|
+
def finish_browser_recording
|
|
500
|
+
@browser_recorder&.complete! if @browser_recorder&.recording_started?
|
|
501
|
+
rescue StandardError => e
|
|
502
|
+
Rails.logger.warn("[AgentExecutionService] could not complete the session recording: #{e.message}")
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Routes one tool call to its implementation. A run with an engine
|
|
506
|
+
# toolset reaches that toolset and request_secret, and nothing else.
|
|
507
|
+
def dispatch_tool(name, kwargs, tool_call_id)
|
|
508
|
+
if engine_toolset
|
|
509
|
+
return request_secret(tool_call_id, kwargs) if name.to_s == "request_secret"
|
|
510
|
+
|
|
511
|
+
return engine_toolset.call(name.to_s, kwargs)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
case name.to_s
|
|
515
|
+
when "ask_user" then ask_user(tool_call_id, kwargs)
|
|
516
|
+
when "request_approval" then request_approval(tool_call_id, kwargs)
|
|
517
|
+
when "request_secret" then request_secret(tool_call_id, kwargs)
|
|
518
|
+
when "save_memory"
|
|
519
|
+
entry = agent_memory.remember(
|
|
520
|
+
kwargs[:content].to_s,
|
|
521
|
+
source_agent: agent_class_name,
|
|
522
|
+
category: kwargs[:category]
|
|
523
|
+
)
|
|
524
|
+
{ saved: true, id: entry.id, content: entry.content }
|
|
525
|
+
when "recall_memory"
|
|
526
|
+
entries = agent_memory.recall(limit: kwargs[:limit], category: kwargs[:category])
|
|
527
|
+
{
|
|
528
|
+
count: entries.size,
|
|
529
|
+
entries: entries.map do |entry|
|
|
530
|
+
{
|
|
531
|
+
content: entry.content,
|
|
532
|
+
category: entry.category,
|
|
533
|
+
source_agent: entry.source_agent,
|
|
534
|
+
created_at: entry.created_at&.iso8601
|
|
535
|
+
}.compact
|
|
536
|
+
end
|
|
537
|
+
}
|
|
538
|
+
when "call_agent"
|
|
539
|
+
call_agent(slug: kwargs[:slug], message: kwargs[:message])
|
|
540
|
+
when "request_handoff"
|
|
541
|
+
request_handoff(**kwargs.slice(:reason, :url, :form_values, :instructions))
|
|
542
|
+
else
|
|
543
|
+
# A tool one of the agent's own MCP servers serves is called there;
|
|
544
|
+
# AgentToolbox answers the rest. A browser action is also written to
|
|
545
|
+
# the run's session recording, so the run plays back in Session Replay
|
|
546
|
+
# and can hand off.
|
|
547
|
+
# `actor:` comes from the run, never from kwargs (see
|
|
548
|
+
# ACTOR_KEYWORDS): it is who the run is for, not what it is about.
|
|
549
|
+
browser_recorder.intercept(tool_name: name.to_s, parameters: kwargs) do
|
|
550
|
+
mcp_dispatcher.call(name, kwargs) || AgentToolbox.call(name, actor: actor, **kwargs)
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# A confirm request for a call to a tool on the agent's approval list,
|
|
556
|
+
# until a person approved that call. A declined call is never dispatched
|
|
557
|
+
# again, so it does not reach this. The input tools ask on their own and
|
|
558
|
+
# are never gated.
|
|
559
|
+
def approval_request(name, kwargs, tool_call_id)
|
|
560
|
+
return nil if AgentToolbox::INPUT_FUNCTIONS.include?(name.to_s)
|
|
561
|
+
return nil unless @agent_record.approval_required_tools.include?(name.to_s)
|
|
562
|
+
return nil if ActiveAgent::InputRequest.answer_for(tool_call_id) == true
|
|
563
|
+
|
|
564
|
+
ActiveAgent::InputRequest.confirm("Allow #{name} to run?", metadata: { arguments: kwargs })
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Asks a question, or, with options, for one of them. Dispatched again
|
|
568
|
+
# with the answer, it returns that answer as the call's result.
|
|
569
|
+
def ask_user(tool_call_id, kwargs)
|
|
570
|
+
answer = ActiveAgent::InputRequest.answer_for(tool_call_id)
|
|
571
|
+
return { answer: answer } unless answer.nil?
|
|
572
|
+
|
|
573
|
+
question = kwargs[:question].to_s
|
|
574
|
+
options = Array(kwargs[:options]).map(&:to_s).reject(&:blank?)
|
|
575
|
+
return ActiveAgent::InputRequest.text(question) if options.empty?
|
|
576
|
+
|
|
577
|
+
ActiveAgent::InputRequest.choice(question, options: options)
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# Asks a person to approve the action the model describes.
|
|
581
|
+
def request_approval(tool_call_id, kwargs)
|
|
582
|
+
return { approved: true } if ActiveAgent::InputRequest.answer_for(tool_call_id) == true
|
|
583
|
+
|
|
584
|
+
ActiveAgent::InputRequest.confirm(kwargs[:action].to_s, metadata: { arguments: kwargs })
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Asks a person for a secret and, dispatched again with it, hands the
|
|
588
|
+
# value to the agent's registered handler (SecretRequests). The model
|
|
589
|
+
# reads only that it was provided.
|
|
590
|
+
def request_secret(tool_call_id, kwargs)
|
|
591
|
+
handler = SecretRequests.handler_for(@agent_record)
|
|
592
|
+
return { error: "request_secret is not available to this agent" } unless handler
|
|
593
|
+
|
|
594
|
+
name = kwargs[:name].to_s
|
|
595
|
+
value = ActiveAgent::InputRequest.answer_for(tool_call_id)
|
|
596
|
+
if value.nil?
|
|
597
|
+
refusal = SecretRequests.refusal(@agent_record, run: @run, name: name)
|
|
598
|
+
return { error: refusal } if refusal
|
|
599
|
+
|
|
600
|
+
return ActiveAgent::InputRequest.secret(
|
|
601
|
+
SecretRequests.prompt(@agent_record, run: @run, name: name, prompt: kwargs[:prompt].presence || "Provide #{name}")
|
|
602
|
+
)
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
arguments = { run: @run, name: name, value: value.to_s }
|
|
606
|
+
arguments[:tool_call_id] = tool_call_id if takes_keyword?(handler, :tool_call_id)
|
|
607
|
+
handler.call(**arguments)
|
|
608
|
+
{ provided: true, name: name }
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Whether +callable+ (a block, or an object's #call) takes +keyword+.
|
|
612
|
+
def takes_keyword?(callable, keyword)
|
|
613
|
+
parameters = callable.is_a?(Proc) ? callable.parameters : callable.method(:call).parameters
|
|
614
|
+
parameters.any? { |type, name| type == :keyrest || (%i[key keyreq].include?(type) && name == keyword) }
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# The names of the tools the run's earlier segments called, for a resumed
|
|
618
|
+
# run's metadata; empty for a run's first segment.
|
|
619
|
+
def earlier_tool_calls
|
|
620
|
+
return [] unless resuming?
|
|
621
|
+
|
|
622
|
+
Array(@run.output_metadata.to_h["tool_calls"])
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# How many progress events the run already holds, so a resumed segment's
|
|
626
|
+
# event ids do not repeat its earlier segments'.
|
|
627
|
+
def recorded_event_count
|
|
628
|
+
Array(@run.logs).count { |entry| entry.is_a?(Hash) && entry.key?("eid") }
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Returns +value+ with every secret answer this execution was given
|
|
632
|
+
# replaced by ActiveAgent::InputRequest::FILTERED.
|
|
633
|
+
def scrub_secrets(value)
|
|
634
|
+
@secrets.empty? ? value : ActiveAgent::InputRequest.scrub(value, @secrets)
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Returns a flattened span with the secrets scrubbed from its attribute
|
|
638
|
+
# values, status message and events. Its ids, name and every key are kept
|
|
639
|
+
# whole, so a secret can never break the span's links to its parent.
|
|
640
|
+
def scrub_span(span)
|
|
641
|
+
return span if @secrets.empty?
|
|
642
|
+
|
|
643
|
+
span.merge(
|
|
644
|
+
"attributes" => scrub_values(span["attributes"]),
|
|
645
|
+
"status_message" => scrub_secrets(span["status_message"]),
|
|
646
|
+
"events" => scrub_values(span["events"])
|
|
647
|
+
)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# Returns +value+ with the secrets scrubbed from the values at any depth
|
|
651
|
+
# of a Hash or Array, and every Hash key as it is.
|
|
652
|
+
def scrub_values(value)
|
|
653
|
+
case value
|
|
654
|
+
when Hash then value.transform_values { |item| scrub_values(item) }
|
|
655
|
+
when Array then value.map { |item| scrub_values(item) }
|
|
656
|
+
else scrub_secrets(value)
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# Options added to the provider's for this run, such as a
|
|
661
|
+
# max_tool_turns cap above the provider's default.
|
|
662
|
+
def generation_options
|
|
663
|
+
{}
|
|
664
|
+
end
|
|
665
|
+
|
|
410
666
|
# Maximum agent-to-agent delegation depth for the call_agent tool. A
|
|
411
667
|
# thread-local counter guards it because the sub-agent runs synchronously
|
|
412
668
|
# on the same thread via Agent#test_execute.
|
|
@@ -417,9 +673,34 @@ module ActionAgent
|
|
|
417
673
|
# sub-run is a real AgentRun with its own trace.
|
|
418
674
|
# One dispatcher per run, so every tool call shares the MCP sessions the
|
|
419
675
|
# first call opens. A run given a checkout sandbox (an evaluation or a
|
|
420
|
-
# runner run against it) reaches that runtime too
|
|
676
|
+
# runner run against it) reaches that runtime too, and the sandbox's
|
|
677
|
+
# browser when it was running as the run was created.
|
|
421
678
|
def mcp_dispatcher
|
|
422
|
-
@mcp_dispatcher ||= MCPToolDispatcher.new(
|
|
679
|
+
@mcp_dispatcher ||= MCPToolDispatcher.new(
|
|
680
|
+
@agent_record, extra_server_keys: [ @run.try(:sandbox_server_key), @run.try(:browser_server_key) ].compact
|
|
681
|
+
)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# How many rows of each credential the recording secrets are read from.
|
|
685
|
+
RECORDING_SECRET_LOOKUP_LIMIT = 50
|
|
686
|
+
|
|
687
|
+
def browser_recorder
|
|
688
|
+
@browser_recorder ||= MCPRecordingMiddleware.new(agent_run: @run, secrets: -> { recording_secrets })
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# The credentials the run's owner holds, scrubbed from the browser
|
|
692
|
+
# actions the run records: provider keys, the GitHub token, and the
|
|
693
|
+
# runtime and browser tokens of the sandbox the run reaches.
|
|
694
|
+
def recording_secrets
|
|
695
|
+
sandbox_id = @run.try(:sandbox_id)
|
|
696
|
+
sandbox = sandbox_id && SandboxSession.for_owner(owner).find_by(session_id: sandbox_id)
|
|
697
|
+
[
|
|
698
|
+
*ProviderKey.for_owner(owner).limit(RECORDING_SECRET_LOOKUP_LIMIT).pluck(:credential, :api_key).flatten,
|
|
699
|
+
*GithubConnection.for_owner(owner).limit(RECORDING_SECRET_LOOKUP_LIMIT).pluck(:access_token),
|
|
700
|
+
sandbox&.runtime_mcp_token,
|
|
701
|
+
sandbox&.browser_token,
|
|
702
|
+
*owner_provider_options(requested_provider).values_at(:access_token, :api_key)
|
|
703
|
+
].compact
|
|
423
704
|
end
|
|
424
705
|
|
|
425
706
|
# Splits the offered schemas the way `tool_schemas` assembles them, so the
|
|
@@ -456,6 +737,8 @@ module ActionAgent
|
|
|
456
737
|
Thread.current[:agent_call_depth] = depth + 1
|
|
457
738
|
begin
|
|
458
739
|
sub_run = target.test_execute(message.to_s)
|
|
740
|
+
return delegated_input_required(target, sub_run) if sub_run.awaiting_input?
|
|
741
|
+
|
|
459
742
|
{
|
|
460
743
|
agent: target.slug,
|
|
461
744
|
run_id: sub_run.id,
|
|
@@ -467,6 +750,24 @@ module ActionAgent
|
|
|
467
750
|
end
|
|
468
751
|
end
|
|
469
752
|
|
|
753
|
+
# A called agent that paused to ask a person cannot be resumed from here:
|
|
754
|
+
# nothing holds its checkpoint once this call returns. Its requests are
|
|
755
|
+
# cancelled, and its questions go back to the calling model, in the
|
|
756
|
+
# shape a framework delegation that paused returns.
|
|
757
|
+
def delegated_input_required(target, sub_run)
|
|
758
|
+
questions = sub_run.input_requests.pending.order(:id).pluck(:prompt)
|
|
759
|
+
sub_run.cancel!("Paused for input, which an agent called with call_agent cannot do")
|
|
760
|
+
|
|
761
|
+
{
|
|
762
|
+
error: "input_required",
|
|
763
|
+
agent: target.slug,
|
|
764
|
+
run_id: sub_run.id,
|
|
765
|
+
questions: questions,
|
|
766
|
+
message: "#{target.slug} stopped to ask the user for input, which an agent called with call_agent cannot do. " \
|
|
767
|
+
"Answer with the information you already have, or ask the user yourself."
|
|
768
|
+
}
|
|
769
|
+
end
|
|
770
|
+
|
|
470
771
|
# Keeps the persisted context's instructions current so the Interactions
|
|
471
772
|
# view can render the conversation's system message.
|
|
472
773
|
def sync_context_instructions
|
|
@@ -516,6 +817,7 @@ module ActionAgent
|
|
|
516
817
|
action = action_name
|
|
517
818
|
run_trace_id = trace_id
|
|
518
819
|
tool_definitions = tool_schemas
|
|
820
|
+
extra_options = generation_options
|
|
519
821
|
service = self
|
|
520
822
|
|
|
521
823
|
# A dashboard-authored agent has no Ruby class — it is rows: a tool
|
|
@@ -532,6 +834,7 @@ module ActionAgent
|
|
|
532
834
|
if (host_class = resolved_host_class)
|
|
533
835
|
return run_host_class(host_class, actor: actor, action: action, run_trace_id: run_trace_id)
|
|
534
836
|
end
|
|
837
|
+
resume = @resume
|
|
535
838
|
|
|
536
839
|
agent_class = Class.new(ActiveAgent::Base) do
|
|
537
840
|
# SolidAgent persists contexts under self.class.name; anonymous
|
|
@@ -563,9 +866,9 @@ module ActionAgent
|
|
|
563
866
|
|
|
564
867
|
if effective_provider == :mock
|
|
565
868
|
# Test environment only (see #provider_available?).
|
|
566
|
-
generate_with :mock
|
|
869
|
+
generate_with :mock, **extra_options
|
|
567
870
|
else
|
|
568
|
-
generate_with effective_provider, model: provider_model, **model_options
|
|
871
|
+
generate_with effective_provider, model: provider_model, **model_options, **extra_options
|
|
569
872
|
end
|
|
570
873
|
|
|
571
874
|
# Expose the agent's server-executable tools as public methods so the
|
|
@@ -593,7 +896,8 @@ module ActionAgent
|
|
|
593
896
|
load_context(contextable: agent_record)
|
|
594
897
|
end
|
|
595
898
|
|
|
596
|
-
|
|
899
|
+
# A resume replaces the conversation with its checkpoint's.
|
|
900
|
+
options = { messages: resume ? [] : service.prompt_messages }
|
|
597
901
|
options[:instructions] = instructions if instructions.present?
|
|
598
902
|
options[:tools] = tool_definitions if tool_definitions.present?
|
|
599
903
|
prompt(**options)
|
|
@@ -608,6 +912,8 @@ module ActionAgent
|
|
|
608
912
|
# run detail API finds the run's slice of the conversation), with
|
|
609
913
|
# the attachment manifest alongside.
|
|
610
914
|
define_method(:persist_prompt_to_context) do
|
|
915
|
+
return if resuming?
|
|
916
|
+
|
|
611
917
|
text = service.user_text
|
|
612
918
|
return unless context && text.present?
|
|
613
919
|
|
|
@@ -615,6 +921,16 @@ module ActionAgent
|
|
|
615
921
|
end
|
|
616
922
|
private :persist_prompt_to_context
|
|
617
923
|
|
|
924
|
+
# A paused generation has no reply yet, and its tool-call turn holds
|
|
925
|
+
# calls without results. The segment that finishes the run records
|
|
926
|
+
# the reply and the whole tool exchange.
|
|
927
|
+
define_method(:persist_generation_to_context) do
|
|
928
|
+
return if generation_response.try(:awaiting_input?)
|
|
929
|
+
|
|
930
|
+
super()
|
|
931
|
+
end
|
|
932
|
+
private :persist_generation_to_context
|
|
933
|
+
|
|
618
934
|
# solid_agent persists the tool exchange from the response's
|
|
619
935
|
# tool-role messages. The Responses API carries function calls as
|
|
620
936
|
# items rather than messages, so that list is empty and the
|
|
@@ -631,7 +947,14 @@ module ActionAgent
|
|
|
631
947
|
# `as` carries the caller onto the agent instance, so an agent's own
|
|
632
948
|
# before_action callbacks (ActiveAgent::Authorization) authorize
|
|
633
949
|
# against the same person the tools are scoped to.
|
|
634
|
-
agent_class.as(actor).public_send(action)
|
|
950
|
+
perform(agent_class.as(actor).public_send(action))
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
# Generates, or continues the paused generation this execution resumes.
|
|
954
|
+
def perform(generation)
|
|
955
|
+
return generation.generate_now unless resuming?
|
|
956
|
+
|
|
957
|
+
generation.resume_now(checkpoint: @resume[:checkpoint], answers: @resume[:answers])
|
|
635
958
|
end
|
|
636
959
|
|
|
637
960
|
# Function-calling schemas for the agent's enabled tools that have
|
|
@@ -646,15 +969,38 @@ module ActionAgent
|
|
|
646
969
|
# rest. Without the first half a tool the agent declares is never offered to
|
|
647
970
|
# the model, which then answers from memory instead of calling it. Memoized
|
|
648
971
|
# because listing a server's tools is a request to that server.
|
|
972
|
+
#
|
|
973
|
+
# A run that reaches its sandbox's browser gets the browser's tools from
|
|
974
|
+
# the MCP half only: the toolbox's browser tools have the same names, and
|
|
975
|
+
# a provider refuses a tool list that names one twice.
|
|
649
976
|
def tool_schema_halves
|
|
650
977
|
@tool_schema_halves ||=
|
|
651
978
|
if provider == :mock
|
|
652
979
|
[ [], [] ]
|
|
980
|
+
elsif engine_toolset
|
|
981
|
+
[ [], engine_toolset.definitions ]
|
|
653
982
|
else
|
|
654
|
-
[
|
|
983
|
+
[
|
|
984
|
+
mcp_dispatcher.tool_definitions,
|
|
985
|
+
AgentToolbox.definitions_for(@agent_record.tools, browser_attached: mcp_dispatcher.browser_attached?) + secret_request_definitions
|
|
986
|
+
]
|
|
655
987
|
end
|
|
656
988
|
end
|
|
657
989
|
|
|
990
|
+
# The tools of a run the engine started for an agent it defines (see
|
|
991
|
+
# ProjectSetup.toolset_for). They replace whatever tools and MCP servers
|
|
992
|
+
# the agent record names. Nil for any other run.
|
|
993
|
+
def engine_toolset
|
|
994
|
+
return @engine_toolset if defined?(@engine_toolset)
|
|
995
|
+
|
|
996
|
+
@engine_toolset = ProjectSetup.toolset_for(@agent_record, @run)
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
# request_secret, for an agent the engine registered a handler for.
|
|
1000
|
+
def secret_request_definitions
|
|
1001
|
+
SecretRequests.handler_for(@agent_record) ? [ AgentToolbox::REQUEST_SECRET_DEFINITION ] : []
|
|
1002
|
+
end
|
|
1003
|
+
|
|
658
1004
|
# Persists the tool interaction stream to the solid_agent conversation
|
|
659
1005
|
# context so the Interactions view shows the full agent <-> tool
|
|
660
1006
|
# exchange. Deduped by tool_call_id — newer solid_agent versions persist
|
|
@@ -673,8 +1019,8 @@ module ActionAgent
|
|
|
673
1019
|
next if tool_call_id.present? && context.messages.exists?(role: "tool", tool_call_id: tool_call_id)
|
|
674
1020
|
|
|
675
1021
|
# Provider tool messages often carry no name (Ollama's don't); fall
|
|
676
|
-
# back to the service's own invocation record
|
|
677
|
-
invocation =
|
|
1022
|
+
# back to the service's own invocation record.
|
|
1023
|
+
invocation = tool_invocation_for(tool_call_id, index)
|
|
678
1024
|
name = (message.name if message.respond_to?(:name)).presence || invocation&.dig(:name)
|
|
679
1025
|
|
|
680
1026
|
context.add_tool_message(
|
|
@@ -689,11 +1035,31 @@ module ActionAgent
|
|
|
689
1035
|
Rails.logger.error("[AgentExecutionService] Failed to persist tool messages: #{e.message}")
|
|
690
1036
|
end
|
|
691
1037
|
|
|
1038
|
+
# The calls this execution ran to a result: not the ones that asked a
|
|
1039
|
+
# person and left the run paused.
|
|
1040
|
+
def completed_tool_invocations
|
|
1041
|
+
@tool_invocations.reject { |invocation| invocation[:awaiting_input] }
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
# The invocation record for a response's tool message: by tool call id
|
|
1045
|
+
# when both carry one, else by position among the tool messages. A
|
|
1046
|
+
# resumed segment's response also holds the tool messages of earlier
|
|
1047
|
+
# segments, which have no record here, so it matches by id only.
|
|
1048
|
+
def tool_invocation_for(tool_call_id, index)
|
|
1049
|
+
invocations = completed_tool_invocations
|
|
1050
|
+
if tool_call_id.present?
|
|
1051
|
+
match = invocations.find { |invocation| invocation[:tool_call_id].to_s == tool_call_id.to_s }
|
|
1052
|
+
return match if match
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
invocations[index] unless resuming?
|
|
1056
|
+
end
|
|
1057
|
+
|
|
692
1058
|
# Tool names for run metadata. Spans are recorded live in execute_tool;
|
|
693
1059
|
# the response-message scan only covers calls the provider executed
|
|
694
1060
|
# without routing through the service (none today, but cheap insurance).
|
|
695
1061
|
def record_tool_spans(root_span, response)
|
|
696
|
-
return
|
|
1062
|
+
return completed_tool_invocations.map { |invocation| invocation[:name] } if @tool_invocations.any?
|
|
697
1063
|
|
|
698
1064
|
messages = response.respond_to?(:messages) ? Array(response.messages) : []
|
|
699
1065
|
tool_messages = messages.select { |message| message.respond_to?(:role) && message.role.to_s == "tool" }
|
|
@@ -732,7 +1098,7 @@ module ActionAgent
|
|
|
732
1098
|
def run_host_class(klass, actor:, action:, run_trace_id:)
|
|
733
1099
|
generation = klass.as(actor).public_send(action, **host_action_arguments(klass, action))
|
|
734
1100
|
generation.prompt_options[:trace_id] = run_trace_id if generation.respond_to?(:prompt_options)
|
|
735
|
-
generation
|
|
1101
|
+
perform(generation)
|
|
736
1102
|
end
|
|
737
1103
|
|
|
738
1104
|
# A code agent's action takes named arguments (`ask(question:)`), so the
|
|
@@ -744,6 +1110,8 @@ module ActionAgent
|
|
|
744
1110
|
keyword ? { keyword.to_sym => user_text } : {}
|
|
745
1111
|
end
|
|
746
1112
|
|
|
1113
|
+
# Unresolved credentials propagate, so the run fails with that error
|
|
1114
|
+
# rather than with ProviderNotConfiguredError.
|
|
747
1115
|
def provider_available?(name)
|
|
748
1116
|
# The gem's mock provider is a test double: accepted only in the test
|
|
749
1117
|
# environment so app runs can never store fabricated output.
|
|
@@ -758,18 +1126,22 @@ module ActionAgent
|
|
|
758
1126
|
else
|
|
759
1127
|
config[:access_token].present?
|
|
760
1128
|
end
|
|
1129
|
+
rescue ProviderCredentials::Unresolved
|
|
1130
|
+
raise
|
|
761
1131
|
rescue StandardError
|
|
762
1132
|
false
|
|
763
1133
|
end
|
|
764
1134
|
|
|
765
|
-
# Credential overrides for +name
|
|
766
|
-
# this owner
|
|
1135
|
+
# Credential overrides for +name+, as ProviderCredentials resolves them
|
|
1136
|
+
# for this owner and actor.
|
|
767
1137
|
def owner_provider_options(name)
|
|
768
1138
|
@owner_provider_options ||= {}
|
|
769
|
-
@owner_provider_options[name.to_s] ||=
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1139
|
+
@owner_provider_options[name.to_s] ||=
|
|
1140
|
+
ProviderCredentials.resolve(owner: owner, actor: credentials_actor, provider: name).options
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
def credentials_actor
|
|
1144
|
+
@credentials_actor || @run&.actor
|
|
773
1145
|
end
|
|
774
1146
|
|
|
775
1147
|
def build_root_span
|
|
@@ -971,7 +1343,7 @@ module ActionAgent
|
|
|
971
1343
|
environment: Rails.env,
|
|
972
1344
|
timestamp: Time.current.iso8601(6),
|
|
973
1345
|
resource_attributes: { "platform.agent_id" => @agent_record.id, "platform.run_id" => @run.id },
|
|
974
|
-
spans: flatten_spans(root_span)
|
|
1346
|
+
spans: flatten_spans(root_span).map { |span| scrub_span(span) }
|
|
975
1347
|
}.as_json
|
|
976
1348
|
|
|
977
1349
|
sdk_info = {
|
|
@@ -983,7 +1355,13 @@ module ActionAgent
|
|
|
983
1355
|
|
|
984
1356
|
trace_model = ActionAgent.trace_model
|
|
985
1357
|
tenant = ActionAgent.tenant_for(owner)
|
|
986
|
-
|
|
1358
|
+
existing = trace_model.for_account(tenant).find_by(trace_id: root_span.trace_id)
|
|
1359
|
+
# A resumed segment shares its run's trace id, and its spans join the
|
|
1360
|
+
# trace the run's earlier segments recorded.
|
|
1361
|
+
if existing
|
|
1362
|
+
existing.append_segment!(payload["spans"]) if resuming? && existing.respond_to?(:append_segment!)
|
|
1363
|
+
return
|
|
1364
|
+
end
|
|
987
1365
|
|
|
988
1366
|
trace_model.create_from_payload(payload, sdk_info, account: tenant, agent: @agent_record)
|
|
989
1367
|
rescue StandardError => e
|