Class: ActionAgent::AgentExecutionService

Inherits:
Object
  • Object
show all
Defined in:
app/services/action_agent/agent_execution_service.rb

Overview

Executes a dashboard-configured Agent through the activeagent gem and records a telemetry trace for the run.

The requested provider is used when credentials are available — the account's own provider key (Settings -> Provider API Keys) when configured, else the platform keys in config/active_agent.yml. Without credentials the run fails with an actionable error: execution never falls back to mock output, so every stored run, trace and generation reflects a real provider response. (The gem's mock provider is a test double, accepted only in the test environment.)

Traces are built with the gem's ActiveAgent::Telemetry::Span and persisted through TelemetryTrace.create_from_payload — the same normalizer used by the telemetry ingest endpoint — so platform-executed runs and SDK-reported runs share one pipeline.

Defined Under Namespace

Classes: ProviderNotConfiguredError

Constant Summary collapse

SERVICE_NAME =
"activeagents-platform"

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(agent_record, run) ⇒ AgentExecutionService

Returns a new instance of AgentExecutionService.



30
31
32
33
34
35
# File 'app/services/action_agent/agent_execution_service.rb', line 30

def initialize(agent_record, run)
  @agent_record = agent_record
  @run = run
  @tool_invocations = []
  @event_sequence = 0
end

Class Method Details

.call(agent_record, run) ⇒ Object



26
27
28
# File 'app/services/action_agent/agent_execution_service.rb', line 26

def self.call(agent_record, run)
  new(agent_record, run).call
end

Instance Method Details

#action_nameObject

The named action this run invokes (falls back to the default). Named actions execute under composed instructions: base + the action's prompt.



176
177
178
179
180
181
# File 'app/services/action_agent/agent_execution_service.rb', line 176

def action_name
  @action_name ||= begin
    requested = @run.action_name.presence || Agent::DEFAULT_ACTION
    @agent_record.available_actions.include?(requested) ? requested : Agent::DEFAULT_ACTION
  end
end

#callObject



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'app/services/action_agent/agent_execution_service.rb', line 61

def call
  root_span = @root_span = build_root_span
  record_prompt_span(root_span)
  llm_span = root_span.add_span(
    "llm.generate",
    span_type: :llm,
    "llm.provider" => provider.to_s,
    "llm.model" => model
  )

  llm_eid = next_event_id
  llm_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  emit_event(eid: llm_eid, kind: "llm", label: "#{provider}/#{model} generating", status: "started")

  begin
    response = generate!
    usage = response.usage
    input = usage&.input_tokens.to_i
    output = usage&.output_tokens.to_i
    thinking = usage&.reasoning_tokens.to_i

    llm_span.set_tokens(input: input, output: output, thinking: thinking)
    llm_span.finish
    emit_event(
      eid: llm_eid, kind: "llm", label: "#{provider}/#{model} generating", status: "done",
      duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - llm_started) * 1000).round,
      detail: "#{input} in / #{output} out tokens#{thinking.positive? ? " / #{thinking} thinking" : ""}"
    )
    tool_calls = record_tool_spans(root_span, response)
    persist_tool_messages(response)
    sync_context_instructions
    root_span.finish

    {
      output: response.message&.content,
      metadata: {
        provider: provider.to_s,
        model: model,
        action: action_name,
        instructions: composed_instructions,
        requested_provider: @agent_record.provider,
        trace_id: root_span.trace_id,
        context_id: conversation_context&.id,
        tool_calls: tool_calls
      },
      usage: {
        input_tokens: input,
        output_tokens: output,
        total_tokens: usage&.total_tokens || input + output + thinking
      }
    }
  rescue StandardError => e
    llm_span.record_error(e)
    llm_span.finish
    root_span.record_error(e)
    root_span.finish
    emit_event(eid: llm_eid, kind: "llm", label: "#{provider}/#{model} generating", status: "error", detail: e.message)
    raise
  ensure
    record_trace(root_span)
  end
end

#composed_instructionsObject



183
184
185
# File 'app/services/action_agent/agent_execution_service.rb', line 183

def composed_instructions
  @composed_instructions ||= @agent_record.composed_instructions_for(action_name)
end

#emit_event(**kwargs) ⇒ Object

Emits a progress event on the run (streamed to the UI by pollers). Never lets telemetry break execution.



39
40
41
42
43
# File 'app/services/action_agent/agent_execution_service.rb', line 39

def emit_event(**kwargs)
  @run.append_event(**kwargs)
rescue StandardError => e
  Rails.logger.warn("[AgentExecutionService] event emit failed: #{e.message}")
end

#event_result_preview(result) ⇒ Object

Compact human preview of a tool result for the live activity feed: prefer the long readable field (page text, sub-agent output) over JSON.



52
53
54
55
56
57
58
59
# File 'app/services/action_agent/agent_execution_service.rb', line 52

def event_result_preview(result)
  return nil unless result.respond_to?(:[])

  readable = %i[text output content body].filter_map { |field| result[field] || result[field.to_s] }
    .find { |value| value.is_a?(String) && value.strip.present? }
  preview = readable ? readable.gsub(/\s+/, " ").strip : result.to_json
  preview.byteslice(0, 1000).to_s.scrub
end

#execute_tool(name, **kwargs) ⇒ Object

Routes a provider tool call to its implementation: memory tools bind to the agent record's AgentMemory (the solid_agent HasMemory contract); everything else is stateless and lives in AgentToolbox.

Each call is wrapped in a live :tool span (real start/end around the execution) and recorded in @tool_invocations so tool names, arguments and durations reach Traces and the persisted conversation.



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'app/services/action_agent/agent_execution_service.rb', line 194

def execute_tool(name, **kwargs)
  # Record the absolute URL browse_page will actually fetch, not the bare
  # path the model passed — spans/events/persisted args stay unambiguous.
  kwargs[:url] = AgentToolbox.resolve_browse_url(kwargs[:url]) if name.to_s == "browse_page" && kwargs[:url]

  span = @root_span&.add_span("tool.#{name}", span_type: :tool)
  span&.set_attribute("tool.name", name.to_s)
  # tool.input.args is the key the Traces UI and TraceInteractionSerializer
  # read — the call's in: side.
  span&.set_attribute("tool.input.args", kwargs.to_json.byteslice(0, 500).to_s.scrub) if kwargs.present?
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)

  event_kind = name.to_s == "call_agent" ? "agent" : "tool"
  event_label = name.to_s == "call_agent" ? "call_agent → #{kwargs[:slug]}" : name.to_s
  event_id = next_event_id
  emit_event(eid: event_id, kind: event_kind, label: event_label, status: "started", detail: kwargs.to_json)

  result = begin
    case name.to_s
    when "save_memory"
      entry = agent_memory.remember(
        kwargs[:content].to_s,
        source_agent: agent_class_name,
        category: kwargs[:category]
      )
      { saved: true, id: entry.id, content: entry.content }
    when "recall_memory"
      entries = agent_memory.recall(limit: kwargs[:limit], category: kwargs[:category])
      {
        count: entries.size,
        entries: entries.map do |entry|
          {
            content: entry.content,
            category: entry.category,
            source_agent: entry.source_agent,
            created_at: entry.created_at&.iso8601
          }.compact
        end
      }
    when "call_agent"
      call_agent(slug: kwargs[:slug], message: kwargs[:message])
    else
      AgentToolbox.call(name, **kwargs)
    end
  rescue StandardError => e
    Rails.logger.warn("[AgentExecutionService] Tool #{name} failed: #{e.class} - #{e.message}")
    { error: "#{name} failed: #{e.message}" }
  end

  duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
  errored = result.respond_to?(:key?) && (result.key?(:error) || result.key?("error"))
  span&.set_attribute("tool.error", true) if errored
  # Record the readable side of the result (most tools wrap one long text
  # field); byteslicing whole-JSON breaks it mid-string and the UI can't
  # parse the remainder.
  result_text =
    if result.respond_to?(:key?) && (result[:text] || result["text"]).is_a?(String)
      result[:text] || result["text"]
    else
      result.to_json
    end
  span&.set_attribute("tool.output.result", result_text.byteslice(0, 4000).to_s.scrub)
  span&.finish
  emit_event(
    eid: event_id, kind: event_kind, label: event_label,
    status: errored ? "error" : "done", duration_ms: duration_ms,
    detail: errored ? (result[:error] || result["error"]).to_s : event_result_preview(result)
  )
  @tool_invocations << {
    name: name.to_s,
    arguments: kwargs,
    duration_ms: duration_ms,
    error: errored
  }

  result
end

#next_event_idObject



45
46
47
48
# File 'app/services/action_agent/agent_execution_service.rb', line 45

def next_event_id
  @event_sequence += 1
  "#{@run.id}-#{@event_sequence}"
end

#providerObject

Returns the provider used for this execution, or raises when its credentials are missing.



163
164
165
166
167
168
169
170
171
172
# File 'app/services/action_agent/agent_execution_service.rb', line 163

def provider
  @provider ||= begin
    unless provider_available?(requested_provider)
      raise ProviderNotConfiguredError,
        "No credentials configured for provider '#{requested_provider}' — " \
        "add an API key in Settings -> Provider API Keys, or configure platform credentials"
    end
    requested_provider.to_sym
  end
end

#record_prompt_span(root_span) ⇒ Object

The outbound prompt as a span, in the SDK's attribute shape — gives the Traces UI its System/User conversation rows and lets the context-pressure meter attribute instructions and tool schemas instead of lumping the whole input into "messages".



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'app/services/action_agent/agent_execution_service.rb', line 128

def record_prompt_span(root_span)
  span = root_span.add_span("agent.prompt", span_type: :prompt)
  if composed_instructions.present?
    span.set_attribute("prompt.input.instructions", composed_instructions.to_s.byteslice(0, 6000).to_s.scrub)
  end
  if tool_schemas.present?
    span.set_attribute("prompt.input.tools", tool_schemas.to_json.byteslice(0, 6000).to_s.scrub)
  end
  span.set_attribute(
    "prompt.input.messages",
    [ { role: "user", content: @run.input_prompt.to_s.byteslice(0, 4000).to_s.scrub } ].to_json
  )
  span.set_attribute("messages.count", 1)
  span.finish
rescue StandardError => e
  Rails.logger.warn("[AgentExecutionService] prompt span failed: #{e.message}")
end

#requested_modelObject



157
158
159
# File 'app/services/action_agent/agent_execution_service.rb', line 157

def requested_model
  @requested_model ||= run_params[:model_override].presence || @agent_record.model
end

#requested_providerObject



153
154
155
# File 'app/services/action_agent/agent_execution_service.rb', line 153

def requested_provider
  @requested_provider ||= (run_params[:provider_override].presence || @agent_record.provider).to_s
end

#run_paramsObject

Per-run provider/model overrides (input_params) let callers replay the same agent under a different model — the basis of evaluation comparison runs. Absent overrides, the agent's own configuration applies.



149
150
151
# File 'app/services/action_agent/agent_execution_service.rb', line 149

def run_params
  @run_params ||= (@run.input_params || {}).with_indifferent_access
end