actionagent 1.8.0 → 1.9.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.
Files changed (168) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +39 -10
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +72 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +21 -5
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +148 -0
  66. data/app/models/action_agent/evaluation_run.rb +143 -53
  67. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/model_pricing.rb +214 -34
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +207 -4
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +300 -16
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
  85. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  86. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  87. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  88. data/app/services/action_agent/agent_execution_service.rb +450 -72
  89. data/app/services/action_agent/agent_scorecard.rb +50 -21
  90. data/app/services/action_agent/agent_sync.rb +5 -2
  91. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  92. data/app/services/action_agent/agent_toolbox.rb +215 -9
  93. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  94. data/app/services/action_agent/browser_sidecar.rb +213 -0
  95. data/app/services/action_agent/browser_sign_in.rb +163 -0
  96. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  97. data/app/services/action_agent/claude_code_auth.rb +70 -4
  98. data/app/services/action_agent/codex_session_events.rb +37 -0
  99. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  100. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  101. data/app/services/action_agent/evaluation_fix.rb +101 -0
  102. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  103. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  104. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  105. data/app/services/action_agent/evaluation_runner_service.rb +29 -10
  106. data/app/services/action_agent/evaluation_standing.rb +152 -0
  107. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  108. data/app/services/action_agent/github_client.rb +405 -64
  109. data/app/services/action_agent/live_updates.rb +31 -0
  110. data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
  111. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  112. data/app/services/action_agent/mcp_client.rb +6 -2
  113. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  114. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  115. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  116. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  117. data/app/services/action_agent/project_boot_fix.rb +309 -0
  118. data/app/services/action_agent/project_capabilities.rb +200 -0
  119. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  120. data/app/services/action_agent/project_preflight.rb +178 -0
  121. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  122. data/app/services/action_agent/project_setup.rb +429 -0
  123. data/app/services/action_agent/provider_credentials.rb +102 -0
  124. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  125. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  126. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  127. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  128. data/app/services/action_agent/sandbox_browser.rb +299 -0
  129. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  130. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  131. data/app/services/action_agent/sandbox_mail.rb +103 -0
  132. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  133. data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
  134. data/app/services/action_agent/sandbox_patch.rb +253 -0
  135. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  136. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  137. data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
  138. data/app/services/action_agent/secret_scrubber.rb +33 -4
  139. data/app/services/action_agent/session_index.rb +270 -0
  140. data/app/services/action_agent/session_recording_service.rb +11 -0
  141. data/app/services/action_agent/session_timeline.rb +505 -0
  142. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  143. data/app/views/action_agent/session_player/show.html.erb +19 -0
  144. data/config/routes.rb +153 -2
  145. data/lib/action_agent/claude_login_process.rb +107 -0
  146. data/lib/action_agent/engine.rb +55 -5
  147. data/lib/action_agent/secret_requests.rb +80 -0
  148. data/lib/action_agent/version.rb +1 -1
  149. data/lib/action_agent.rb +513 -22
  150. data/lib/generators/action_agent/install_generator.rb +45 -5
  151. data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
  152. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  153. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  154. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  155. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  156. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  157. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  158. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  159. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  160. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  161. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  162. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  163. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  164. data/lib/tasks/action_agent.rake +33 -0
  165. data/lib/tasks/browser.rake +22 -0
  166. data/lib/tasks/sandbox.rake +1 -1
  167. data/lib/tasks/scenario_catalogs.rake +71 -0
  168. 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
- def self.call(agent_record, run)
49
- new(agent_record, run).call
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 has
53
- # credentials for, in that order (see #available_providers).
54
- def self.available_providers(owner)
55
- new(nil, nil, owner: owner).available_providers
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
- def initialize(agent_record, run, owner: nil)
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 { |name| provider_available?(name) }
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
- record_prompt_span(root_span)
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
- persist_tool_messages(response)
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 @tool_invocations.empty?
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
- @tool_invocations.each do |invocation|
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
- case name.to_s
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
- Rails.logger.warn("[AgentExecutionService] Tool #{name} failed: #{e.class} - #{e.message}")
375
- { error: "#{name} failed: #{e.message}" }
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(@agent_record, extra_server_keys: [ @run.try(:sandbox_server_key) ].compact)
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
- options = { messages: service.prompt_messages }
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).generate_now
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
- [ mcp_dispatcher.tool_definitions, AgentToolbox.definitions_for(@agent_record.tools) ]
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, matched by order.
677
- invocation = @tool_invocations[index]
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 @tool_invocations.map { |invocation| invocation[:name] } if @tool_invocations.any?
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.generate_now
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+: whatever the host app resolves for
766
- # this owner first, then the dashboard's own stored ProviderKey.
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] ||= begin
770
- from_host = ActionAgent.provider_credentials(owner, name.to_s)
771
- from_host.presence || ProviderKey.for_owner(owner).find_by(provider: name.to_s)&.generation_options || {}
772
- end
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
- return if trace_model.for_account(tenant).exists?(trace_id: root_span.trace_id)
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