actionagent 0.0.0 → 1.2.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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +103 -0
  4. data/app/assets/builds/action_agent.css +2 -0
  5. data/app/assets/builds/action_agent.js +163 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
  7. data/app/controllers/action_agent/api/agents_controller.rb +411 -0
  8. data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
  9. data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
  10. data/app/controllers/action_agent/api/base_controller.rb +112 -0
  11. data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
  12. data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
  13. data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
  14. data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
  15. data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
  16. data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
  17. data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
  18. data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
  19. data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
  20. data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
  21. data/app/controllers/action_agent/api/templates_controller.rb +94 -0
  22. data/app/controllers/action_agent/api/tools_controller.rb +58 -0
  23. data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
  24. data/app/controllers/action_agent/api/traces_controller.rb +136 -0
  25. data/app/controllers/action_agent/application_controller.rb +105 -0
  26. data/app/controllers/action_agent/dashboard_controller.rb +104 -0
  27. data/app/controllers/action_agent/traces_controller.rb +121 -0
  28. data/app/jobs/action_agent/agent_execution_job.rb +52 -0
  29. data/app/jobs/action_agent/application_job.rb +12 -0
  30. data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
  31. data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
  32. data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
  33. data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
  34. data/app/jobs/action_agent/trace_retention_job.rb +57 -0
  35. data/app/models/action_agent/agent.rb +343 -0
  36. data/app/models/action_agent/agent_context.rb +129 -0
  37. data/app/models/action_agent/agent_generation.rb +48 -0
  38. data/app/models/action_agent/agent_memory.rb +50 -0
  39. data/app/models/action_agent/agent_memory_entry.rb +14 -0
  40. data/app/models/action_agent/agent_message.rb +43 -0
  41. data/app/models/action_agent/agent_run.rb +151 -0
  42. data/app/models/action_agent/agent_template.rb +182 -0
  43. data/app/models/action_agent/agent_version.rb +48 -0
  44. data/app/models/action_agent/api_key.rb +53 -0
  45. data/app/models/action_agent/application_record.rb +27 -0
  46. data/app/models/action_agent/evaluation.rb +80 -0
  47. data/app/models/action_agent/evaluation_run.rb +20 -0
  48. data/app/models/action_agent/model_pricing.rb +80 -0
  49. data/app/models/action_agent/provider_key.rb +60 -0
  50. data/app/models/action_agent/recording_action.rb +119 -0
  51. data/app/models/action_agent/recording_snapshot.rb +88 -0
  52. data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
  53. data/app/models/action_agent/sandbox_run.rb +45 -0
  54. data/app/models/action_agent/sandbox_session.rb +160 -0
  55. data/app/models/action_agent/session_recording.rb +178 -0
  56. data/app/models/action_agent/telemetry_trace.rb +357 -0
  57. data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
  58. data/app/models/concerns/action_agent/ownable.rb +86 -0
  59. data/app/models/concerns/action_agent/session_recordable.rb +91 -0
  60. data/app/queries/action_agent/agent_executions.rb +201 -0
  61. data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
  62. data/app/serializers/action_agent/interaction_preview.rb +22 -0
  63. data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
  64. data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
  65. data/app/services/action_agent/agent_execution_service.rb +572 -0
  66. data/app/services/action_agent/agent_registrar.rb +197 -0
  67. data/app/services/action_agent/agent_scorecard.rb +191 -0
  68. data/app/services/action_agent/agent_toolbox.rb +504 -0
  69. data/app/services/action_agent/evaluation_runner_service.rb +481 -0
  70. data/app/services/action_agent/mcp_catalog.rb +247 -0
  71. data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
  72. data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
  73. data/app/services/action_agent/playwright_mcp_client.rb +148 -0
  74. data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
  75. data/app/services/action_agent/session_recording_service.rb +228 -0
  76. data/app/services/action_agent/tool_discovery.rb +617 -0
  77. data/app/views/action_agent/dashboard/index.html.erb +5 -0
  78. data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
  79. data/app/views/action_agent/traces/index.html.erb +135 -0
  80. data/app/views/action_agent/traces/metrics.html.erb +145 -0
  81. data/app/views/action_agent/traces/show.html.erb +36 -0
  82. data/app/views/layouts/action_agent/application.html.erb +94 -0
  83. data/app/views/layouts/action_agent/react.html.erb +19 -0
  84. data/config/routes.rb +144 -0
  85. data/lib/action_agent/compatibility.rb +49 -0
  86. data/lib/action_agent/engine.rb +92 -0
  87. data/lib/action_agent/version.rb +5 -0
  88. data/lib/action_agent.rb +388 -0
  89. data/lib/actionagent.rb +6 -0
  90. data/lib/generators/action_agent/install_generator.rb +137 -0
  91. data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
  92. data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
  93. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +334 -0
  94. data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +71 -0
  95. metadata +209 -12
@@ -0,0 +1,572 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Executes a dashboard-configured Agent through the activeagent gem and
5
+ # records a telemetry trace for the run.
6
+ #
7
+ # The requested provider is used when credentials are available — the
8
+ # account's own provider key (Settings -> Provider API Keys) when configured,
9
+ # else the platform keys in config/active_agent.yml. Without credentials the
10
+ # run fails with an actionable error: execution never falls back to mock
11
+ # output, so every stored run, trace and generation reflects a real provider
12
+ # response. (The gem's mock provider is a test double, accepted only in the
13
+ # test environment.)
14
+ #
15
+ # Traces are built with the gem's ActiveAgent::Telemetry::Span and persisted
16
+ # through TelemetryTrace.create_from_payload — the same normalizer used by
17
+ # the telemetry ingest endpoint — so platform-executed runs and SDK-reported
18
+ # runs share one pipeline.
19
+ class AgentExecutionService
20
+ # Raised when the requested provider has no usable credentials. The run is
21
+ # marked failed with this message — never silently degraded to mock output.
22
+ class ProviderNotConfiguredError < StandardError; end
23
+
24
+ SERVICE_NAME = "activeagents-platform"
25
+
26
+ def self.call(agent_record, run)
27
+ new(agent_record, run).call
28
+ end
29
+
30
+ def initialize(agent_record, run)
31
+ @agent_record = agent_record
32
+ @run = run
33
+ @tool_invocations = []
34
+ @event_sequence = 0
35
+ end
36
+
37
+ # Emits a progress event on the run (streamed to the UI by pollers).
38
+ # Never lets telemetry break execution.
39
+ def emit_event(**kwargs)
40
+ @run.append_event(**kwargs)
41
+ rescue StandardError => e
42
+ Rails.logger.warn("[AgentExecutionService] event emit failed: #{e.message}")
43
+ end
44
+
45
+ def next_event_id
46
+ @event_sequence += 1
47
+ "#{@run.id}-#{@event_sequence}"
48
+ end
49
+
50
+ # Compact human preview of a tool result for the live activity feed:
51
+ # prefer the long readable field (page text, sub-agent output) over JSON.
52
+ def event_result_preview(result)
53
+ return nil unless result.respond_to?(:[])
54
+
55
+ readable = %i[text output content body].filter_map { |field| result[field] || result[field.to_s] }
56
+ .find { |value| value.is_a?(String) && value.strip.present? }
57
+ preview = readable ? readable.gsub(/\s+/, " ").strip : result.to_json
58
+ preview.byteslice(0, 1000).to_s.scrub
59
+ end
60
+
61
+ def call
62
+ root_span = @root_span = build_root_span
63
+ record_prompt_span(root_span)
64
+ llm_span = root_span.add_span(
65
+ "llm.generate",
66
+ span_type: :llm,
67
+ "llm.provider" => provider.to_s,
68
+ "llm.model" => model
69
+ )
70
+
71
+ llm_eid = next_event_id
72
+ llm_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
73
+ emit_event(eid: llm_eid, kind: "llm", label: "#{provider}/#{model} generating", status: "started")
74
+
75
+ begin
76
+ response = generate!
77
+ usage = response.usage
78
+ input = usage&.input_tokens.to_i
79
+ output = usage&.output_tokens.to_i
80
+ thinking = usage&.reasoning_tokens.to_i
81
+
82
+ llm_span.set_tokens(input: input, output: output, thinking: thinking)
83
+ llm_span.finish
84
+ emit_event(
85
+ eid: llm_eid, kind: "llm", label: "#{provider}/#{model} generating", status: "done",
86
+ duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - llm_started) * 1000).round,
87
+ detail: "#{input} in / #{output} out tokens#{thinking.positive? ? " / #{thinking} thinking" : ""}"
88
+ )
89
+ tool_calls = record_tool_spans(root_span, response)
90
+ persist_tool_messages(response)
91
+ sync_context_instructions
92
+ root_span.finish
93
+
94
+ {
95
+ output: response.message&.content,
96
+ metadata: {
97
+ provider: provider.to_s,
98
+ model: model,
99
+ action: action_name,
100
+ instructions: composed_instructions,
101
+ requested_provider: @agent_record.provider,
102
+ trace_id: root_span.trace_id,
103
+ context_id: conversation_context&.id,
104
+ tool_calls: tool_calls
105
+ },
106
+ usage: {
107
+ input_tokens: input,
108
+ output_tokens: output,
109
+ total_tokens: usage&.total_tokens || input + output + thinking
110
+ }
111
+ }
112
+ rescue StandardError => e
113
+ llm_span.record_error(e)
114
+ llm_span.finish
115
+ root_span.record_error(e)
116
+ root_span.finish
117
+ emit_event(eid: llm_eid, kind: "llm", label: "#{provider}/#{model} generating", status: "error", detail: e.message)
118
+ raise
119
+ ensure
120
+ record_trace(root_span)
121
+ end
122
+ end
123
+
124
+ # The outbound prompt as a span, in the SDK's attribute shape — gives the
125
+ # Traces UI its System/User conversation rows and lets the context-pressure
126
+ # meter attribute instructions and tool schemas instead of lumping the
127
+ # whole input into "messages".
128
+ def record_prompt_span(root_span)
129
+ span = root_span.add_span("agent.prompt", span_type: :prompt)
130
+ if composed_instructions.present?
131
+ span.set_attribute("prompt.input.instructions", composed_instructions.to_s.byteslice(0, 6000).to_s.scrub)
132
+ end
133
+ if tool_schemas.present?
134
+ span.set_attribute("prompt.input.tools", tool_schemas.to_json.byteslice(0, 6000).to_s.scrub)
135
+ end
136
+ span.set_attribute(
137
+ "prompt.input.messages",
138
+ [ { role: "user", content: @run.input_prompt.to_s.byteslice(0, 4000).to_s.scrub } ].to_json
139
+ )
140
+ span.set_attribute("messages.count", 1)
141
+ span.finish
142
+ rescue StandardError => e
143
+ Rails.logger.warn("[AgentExecutionService] prompt span failed: #{e.message}")
144
+ end
145
+
146
+ # Per-run provider/model overrides (input_params) let callers replay the
147
+ # same agent under a different model — the basis of evaluation comparison
148
+ # runs. Absent overrides, the agent's own configuration applies.
149
+ def run_params
150
+ @run_params ||= (@run.input_params || {}).with_indifferent_access
151
+ end
152
+
153
+ def requested_provider
154
+ @requested_provider ||= (run_params[:provider_override].presence || @agent_record.provider).to_s
155
+ end
156
+
157
+ def requested_model
158
+ @requested_model ||= run_params[:model_override].presence || @agent_record.model
159
+ end
160
+
161
+ # Returns the provider used for this execution, or raises when its
162
+ # credentials are missing.
163
+ def provider
164
+ @provider ||= begin
165
+ unless provider_available?(requested_provider)
166
+ raise ProviderNotConfiguredError,
167
+ "No credentials configured for provider '#{requested_provider}' — " \
168
+ "add an API key in Settings -> Provider API Keys, or configure platform credentials"
169
+ end
170
+ requested_provider.to_sym
171
+ end
172
+ end
173
+
174
+ # The named action this run invokes (falls back to the default). Named
175
+ # actions execute under composed instructions: base + the action's prompt.
176
+ def action_name
177
+ @action_name ||= begin
178
+ requested = @run.action_name.presence || Agent::DEFAULT_ACTION
179
+ @agent_record.available_actions.include?(requested) ? requested : Agent::DEFAULT_ACTION
180
+ end
181
+ end
182
+
183
+ def composed_instructions
184
+ @composed_instructions ||= @agent_record.composed_instructions_for(action_name)
185
+ end
186
+
187
+ # Routes a provider tool call to its implementation: memory tools bind to
188
+ # the agent record's AgentMemory (the solid_agent HasMemory contract);
189
+ # everything else is stateless and lives in AgentToolbox.
190
+ #
191
+ # Each call is wrapped in a live :tool span (real start/end around the
192
+ # execution) and recorded in @tool_invocations so tool names, arguments
193
+ # and durations reach Traces and the persisted conversation.
194
+ def execute_tool(name, **kwargs)
195
+ # Record the absolute URL browse_page will actually fetch, not the bare
196
+ # path the model passed — spans/events/persisted args stay unambiguous.
197
+ kwargs[:url] = AgentToolbox.resolve_browse_url(kwargs[:url]) if name.to_s == "browse_page" && kwargs[:url]
198
+
199
+ span = @root_span&.add_span("tool.#{name}", span_type: :tool)
200
+ span&.set_attribute("tool.name", name.to_s)
201
+ # tool.input.args is the key the Traces UI and TraceInteractionSerializer
202
+ # read — the call's in: side.
203
+ span&.set_attribute("tool.input.args", kwargs.to_json.byteslice(0, 500).to_s.scrub) if kwargs.present?
204
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
205
+
206
+ event_kind = name.to_s == "call_agent" ? "agent" : "tool"
207
+ event_label = name.to_s == "call_agent" ? "call_agent → #{kwargs[:slug]}" : name.to_s
208
+ event_id = next_event_id
209
+ emit_event(eid: event_id, kind: event_kind, label: event_label, status: "started", detail: kwargs.to_json)
210
+
211
+ result = begin
212
+ case name.to_s
213
+ when "save_memory"
214
+ entry = agent_memory.remember(
215
+ kwargs[:content].to_s,
216
+ source_agent: agent_class_name,
217
+ category: kwargs[:category]
218
+ )
219
+ { saved: true, id: entry.id, content: entry.content }
220
+ when "recall_memory"
221
+ entries = agent_memory.recall(limit: kwargs[:limit], category: kwargs[:category])
222
+ {
223
+ count: entries.size,
224
+ entries: entries.map do |entry|
225
+ {
226
+ content: entry.content,
227
+ category: entry.category,
228
+ source_agent: entry.source_agent,
229
+ created_at: entry.created_at&.iso8601
230
+ }.compact
231
+ end
232
+ }
233
+ when "call_agent"
234
+ call_agent(slug: kwargs[:slug], message: kwargs[:message])
235
+ else
236
+ AgentToolbox.call(name, **kwargs)
237
+ end
238
+ rescue StandardError => e
239
+ Rails.logger.warn("[AgentExecutionService] Tool #{name} failed: #{e.class} - #{e.message}")
240
+ { error: "#{name} failed: #{e.message}" }
241
+ end
242
+
243
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
244
+ errored = result.respond_to?(:key?) && (result.key?(:error) || result.key?("error"))
245
+ span&.set_attribute("tool.error", true) if errored
246
+ # Record the readable side of the result (most tools wrap one long text
247
+ # field); byteslicing whole-JSON breaks it mid-string and the UI can't
248
+ # parse the remainder.
249
+ result_text =
250
+ if result.respond_to?(:key?) && (result[:text] || result["text"]).is_a?(String)
251
+ result[:text] || result["text"]
252
+ else
253
+ result.to_json
254
+ end
255
+ span&.set_attribute("tool.output.result", result_text.byteslice(0, 4000).to_s.scrub)
256
+ span&.finish
257
+ emit_event(
258
+ eid: event_id, kind: event_kind, label: event_label,
259
+ status: errored ? "error" : "done", duration_ms: duration_ms,
260
+ detail: errored ? (result[:error] || result["error"]).to_s : event_result_preview(result)
261
+ )
262
+ @tool_invocations << {
263
+ name: name.to_s,
264
+ arguments: kwargs,
265
+ duration_ms: duration_ms,
266
+ error: errored
267
+ }
268
+
269
+ result
270
+ end
271
+
272
+ private
273
+
274
+ # Maximum agent-to-agent delegation depth for the call_agent tool. A
275
+ # thread-local counter guards it because the sub-agent runs synchronously
276
+ # on the same thread via Agent#test_execute.
277
+ MAX_CALL_DEPTH = 2
278
+
279
+ # Executes another agent of the same account synchronously and returns
280
+ # its reply, so agents can delegate to each other as a tool call. The
281
+ # sub-run is a real AgentRun with its own trace.
282
+ def call_agent(slug:, message:)
283
+ depth = Thread.current[:agent_call_depth].to_i
284
+ return { error: "call_agent depth limit (#{MAX_CALL_DEPTH}) reached" } if depth >= MAX_CALL_DEPTH
285
+
286
+ target = workspace_agents.where.not(id: @agent_record.id).find_by(slug: slug.to_s)
287
+ return { error: "No agent with slug '#{slug}' in this workspace" } unless target
288
+
289
+ Thread.current[:agent_call_depth] = depth + 1
290
+ begin
291
+ sub_run = target.test_execute(message.to_s)
292
+ {
293
+ agent: target.slug,
294
+ run_id: sub_run.id,
295
+ status: sub_run.status,
296
+ output: sub_run.output.presence || sub_run.error_message
297
+ }
298
+ ensure
299
+ Thread.current[:agent_call_depth] = depth
300
+ end
301
+ end
302
+
303
+ # Keeps the persisted context's instructions current so the Interactions
304
+ # view can render the conversation's system message.
305
+ def sync_context_instructions
306
+ context = conversation_context
307
+ return unless context
308
+ return if context.instructions == composed_instructions
309
+
310
+ context.update_column(:instructions, composed_instructions)
311
+ rescue StandardError => e
312
+ Rails.logger.warn("[AgentExecutionService] Failed to sync context instructions: #{e.message}")
313
+ end
314
+
315
+ # Agents callable via call_agent: everything the calling agent's owner
316
+ # owns. A single-user install has no owner, so every agent is in scope.
317
+ def workspace_agents
318
+ ActionAgent.agents_for(owner).where.not(status: :archived)
319
+ end
320
+
321
+ def agent_memory
322
+ @agent_memory ||= AgentMemory.for(@agent_record)
323
+ end
324
+
325
+ def model
326
+ requested_model
327
+ end
328
+
329
+ # Newer Anthropic models (Opus 4.7+, Sonnet 5, Fable 5/Mythos 5) reject
330
+ # sampling parameters with a 400 — they are thinking-first models steered
331
+ # by prompting/effort instead.
332
+ SAMPLING_UNSUPPORTED_MODELS = /\Aclaude-(opus-5|opus-4-[78]|sonnet-5|fable-5|mythos-5)/
333
+
334
+ def generate!
335
+ effective_provider = provider
336
+ provider_model = requested_model
337
+ model_options = @agent_record.model_config.to_h.symbolize_keys.slice(:temperature, :max_tokens, :top_p)
338
+ model_options.except!(:temperature, :top_p) if provider_model.to_s.match?(SAMPLING_UNSUPPORTED_MODELS)
339
+ # The owner's own credential (API key, or host URL for ollama)
340
+ # overrides the host app's config/active_agent.yml settings.
341
+ model_options.merge!(owner_provider_options(effective_provider))
342
+ klass_name = agent_class_name
343
+ agent_record = @agent_record
344
+ input = @run.input_prompt
345
+ instructions = composed_instructions
346
+ action = action_name
347
+ run_trace_id = trace_id
348
+ tool_definitions = tool_schemas
349
+ service = self
350
+
351
+ agent_class = Class.new(ActiveAgent::Base) do
352
+ # SolidAgent persists contexts under self.class.name; anonymous
353
+ # classes would fail its agent_name presence validation.
354
+ define_singleton_method(:name) { klass_name }
355
+
356
+ # Persist the conversation (agent_contexts / agent_messages /
357
+ # agent_generations) via solid_agent. contextable: false — the context
358
+ # is loaded explicitly in the action below.
359
+ #
360
+ # The model classes are named explicitly because solid_agent infers
361
+ # bare "AgentContext"/"AgentMessage"/"AgentGeneration" and resolves
362
+ # them against Object. The engine's models are namespaced, so the
363
+ # inferred names only resolve in a host app that happens to have
364
+ # top-level models of its own.
365
+ include SolidAgent::HasContext
366
+ has_context contextable: false,
367
+ class_name: "ActionAgent::AgentContext",
368
+ message_class: "ActionAgent::AgentMessage",
369
+ generation_class: "ActionAgent::AgentGeneration"
370
+
371
+ if effective_provider == :mock
372
+ # Test environment only (see #provider_available?).
373
+ generate_with :mock
374
+ else
375
+ generate_with effective_provider, model: provider_model, **model_options
376
+ end
377
+
378
+ # Expose the agent's server-executable tools as public methods so the
379
+ # gem's tools_function can route provider tool calls to them. The
380
+ # service routes each call to AgentToolbox or, for memory tools, to
381
+ # the run's AgentMemory.
382
+ tool_definitions.each do |definition|
383
+ define_method(definition[:name]) do |**kwargs|
384
+ service.execute_tool(definition[:name], **kwargs)
385
+ end
386
+ end
387
+
388
+ # One method per invokable action (the default plus each named action
389
+ # prompt) — solid_agent keys the persisted context by action_name, so
390
+ # each action gets its own interaction stream.
391
+ define_method action do
392
+ # Thread the run's telemetry trace_id through prompt_options so
393
+ # SolidAgent's provenance (and AgentContext#record_generation_with_
394
+ # provenance!) can correlate the persisted generation with its trace.
395
+ prompt_options[:trace_id] = run_trace_id
396
+ load_context(contextable: agent_record)
397
+
398
+ options = { message: input }
399
+ options[:instructions] = instructions if instructions.present?
400
+ options[:tools] = tool_definitions if tool_definitions.present?
401
+ prompt(**options)
402
+ end
403
+ end
404
+
405
+ agent_class.public_send(action).generate_now
406
+ end
407
+
408
+ # Function-calling schemas for the agent's enabled tools that have
409
+ # server-side implementations (none for mock runs — the mock provider
410
+ # doesn't do tool calling).
411
+ def tool_schemas
412
+ return [] if provider == :mock
413
+
414
+ AgentToolbox.definitions_for(@agent_record.tools)
415
+ end
416
+
417
+ # Persists the tool interaction stream to the solid_agent conversation
418
+ # context so the Interactions view shows the full agent <-> tool
419
+ # exchange. Deduped by tool_call_id — newer solid_agent versions persist
420
+ # these from HasContext already, in which case this is a no-op.
421
+ def persist_tool_messages(response)
422
+ context = conversation_context
423
+ return unless context
424
+ return unless response.respond_to?(:messages)
425
+
426
+ tool_messages = Array(response.messages).select do |message|
427
+ message.respond_to?(:role) && message.role.to_s == "tool"
428
+ end
429
+
430
+ tool_messages.each_with_index do |message, index|
431
+ tool_call_id = message.respond_to?(:tool_call_id) ? message.tool_call_id : nil
432
+ next if tool_call_id.present? && context.messages.exists?(role: "tool", tool_call_id: tool_call_id)
433
+
434
+ # Provider tool messages often carry no name (Ollama's don't); fall
435
+ # back to the service's own invocation record, matched by order.
436
+ invocation = @tool_invocations[index]
437
+ name = (message.name if message.respond_to?(:name)).presence || invocation&.dig(:name)
438
+
439
+ context.add_tool_message(
440
+ tool_call_id: tool_call_id,
441
+ tool_name: name,
442
+ result: (message.content if message.respond_to?(:content)),
443
+ arguments: invocation&.dig(:arguments),
444
+ duration_ms: invocation&.dig(:duration_ms)
445
+ )
446
+ end
447
+ rescue StandardError => e
448
+ Rails.logger.error("[AgentExecutionService] Failed to persist tool messages: #{e.message}")
449
+ end
450
+
451
+ # Tool names for run metadata. Spans are recorded live in execute_tool;
452
+ # the response-message scan only covers calls the provider executed
453
+ # without routing through the service (none today, but cheap insurance).
454
+ def record_tool_spans(root_span, response)
455
+ return @tool_invocations.map { |invocation| invocation[:name] } if @tool_invocations.any?
456
+
457
+ messages = response.respond_to?(:messages) ? Array(response.messages) : []
458
+ tool_messages = messages.select { |message| message.respond_to?(:role) && message.role.to_s == "tool" }
459
+
460
+ tool_messages.map do |message|
461
+ name = message.respond_to?(:name) && message.name.presence || "unknown"
462
+ tool_span = root_span.add_span("tool.#{name}", span_type: :tool)
463
+ tool_span.set_attribute("tool.name", name)
464
+ if message.respond_to?(:tool_call_id) && message.tool_call_id.present?
465
+ tool_span.set_attribute("tool.id", message.tool_call_id)
466
+ end
467
+ tool_span.finish
468
+ name
469
+ end
470
+ end
471
+
472
+ def provider_available?(name)
473
+ # The gem's mock provider is a test double: accepted only in the test
474
+ # environment so app runs can never store fabricated output.
475
+ return Rails.env.test? if name.to_s == "mock"
476
+ return true if owner_provider_options(name).any?
477
+
478
+ config = ActiveAgent.configuration[name.to_sym]
479
+ return false unless config.respond_to?(:[])
480
+
481
+ if name.to_s == "ollama"
482
+ config[:host].present?
483
+ else
484
+ config[:access_token].present?
485
+ end
486
+ rescue StandardError
487
+ false
488
+ end
489
+
490
+ # Credential overrides for +name+: whatever the host app resolves for
491
+ # this owner first, then the dashboard's own stored ProviderKey.
492
+ def owner_provider_options(name)
493
+ @owner_provider_options ||= {}
494
+ @owner_provider_options[name.to_s] ||= begin
495
+ from_host = ActionAgent.provider_credentials(owner, name.to_s)
496
+ from_host.presence || ProviderKey.for_owner(owner).find_by(provider: name.to_s)&.generation_options || {}
497
+ end
498
+ end
499
+
500
+ def build_root_span
501
+ ActiveAgent::Telemetry::Span.new(
502
+ "#{agent_class_name}.prompt",
503
+ trace_id: trace_id,
504
+ span_type: :root,
505
+ "agent.class" => agent_class_name,
506
+ "agent.action" => action_name,
507
+ "agent.provider" => provider.to_s,
508
+ "agent.model" => model,
509
+ "service.name" => SERVICE_NAME,
510
+ "service.environment" => Rails.env,
511
+ "telemetry.sdk.name" => "activeagent",
512
+ "telemetry.sdk.version" => ActiveAgent::VERSION
513
+ )
514
+ end
515
+
516
+ def agent_class_name
517
+ @agent_record.telemetry_agent_class
518
+ end
519
+
520
+ # Reuse the run's trace_id so AgentRun and TelemetryTrace correlate.
521
+ def trace_id
522
+ @trace_id ||= @run.trace_id.presence || SecureRandom.hex(16)
523
+ end
524
+
525
+ # The solid_agent conversation context this execution persisted into
526
+ # (one per agent + action on this platform).
527
+ def conversation_context
528
+ AgentContext.find_by(contextable: @agent_record, agent_name: agent_class_name, action_name: action_name)
529
+ end
530
+
531
+ # The agent's owner under the configured mode; nil when the install
532
+ # has no owner model at all.
533
+ def owner
534
+ @owner ||= @agent_record.owner
535
+ end
536
+
537
+ def record_trace(root_span)
538
+ payload = {
539
+ trace_id: root_span.trace_id,
540
+ service_name: SERVICE_NAME,
541
+ environment: Rails.env,
542
+ timestamp: Time.current.iso8601(6),
543
+ resource_attributes: { "platform.agent_id" => @agent_record.id, "platform.run_id" => @run.id },
544
+ spans: flatten_spans(root_span)
545
+ }.as_json
546
+
547
+ sdk_info = {
548
+ name: "activeagent",
549
+ version: ActiveAgent::VERSION,
550
+ language: "ruby",
551
+ runtime_version: RUBY_VERSION
552
+ }.as_json
553
+
554
+ trace_model = ActionAgent.trace_model
555
+ tenant = ActionAgent.tenant_for(owner)
556
+ return if trace_model.for_account(tenant).exists?(trace_id: root_span.trace_id)
557
+
558
+ trace_model.create_from_payload(payload, sdk_info, account: tenant)
559
+ rescue StandardError => e
560
+ Rails.logger.error("[AgentExecutionService] Failed to record trace #{root_span.trace_id}: #{e.class} - #{e.message}")
561
+ nil
562
+ end
563
+
564
+ # Flattens the span hierarchy the same way the gem's Tracer does before
565
+ # reporting (children stripped, parent_span_id links preserved).
566
+ def flatten_spans(span)
567
+ result = [ span.to_h.except(:children) ]
568
+ span.children.each { |child| result.concat(flatten_spans(child)) }
569
+ result
570
+ end
571
+ end
572
+ end