activeagent 1.0.3 → 1.2.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +132 -0
  3. data/README.md +39 -3
  4. data/lib/active_agent/base.rb +4 -0
  5. data/lib/active_agent/model_capabilities.rb +89 -0
  6. data/lib/active_agent/providers/_base_provider.rb +23 -2
  7. data/lib/active_agent/providers/concerns/exception_handler.rb +12 -1
  8. data/lib/active_agent/providers/errors.rb +140 -0
  9. data/lib/active_agent/providers/ollama/chat/transforms.rb +9 -3
  10. data/lib/active_agent/railtie.rb +5 -0
  11. data/lib/active_agent/telemetry/configuration.rb +115 -171
  12. data/lib/active_agent/telemetry/instrumentation.rb +141 -14
  13. data/lib/active_agent/telemetry/reporter.rb +12 -165
  14. data/lib/active_agent/telemetry/span.rb +18 -245
  15. data/lib/active_agent/telemetry/tool_origin.rb +90 -0
  16. data/lib/active_agent/telemetry/tracer.rb +67 -70
  17. data/lib/active_agent/telemetry.rb +2 -2
  18. data/lib/active_agent/version.rb +1 -1
  19. data/lib/active_agent.rb +1 -1
  20. metadata +38 -42
  21. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +0 -117
  22. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +0 -54
  23. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +0 -126
  24. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +0 -103
  25. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/agent_execution_job.rb +0 -56
  26. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/application_job.rb +0 -14
  27. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_cleanup_job.rb +0 -49
  28. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_provision_job.rb +0 -65
  29. data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +0 -77
  30. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent.rb +0 -256
  31. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_run.rb +0 -113
  32. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_template.rb +0 -208
  33. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_version.rb +0 -60
  34. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/application_record.rb +0 -46
  35. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_action.rb +0 -125
  36. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_snapshot.rb +0 -83
  37. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_run.rb +0 -52
  38. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_session.rb +0 -169
  39. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/session_recording.rb +0 -193
  40. data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +0 -198
  41. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +0 -105
  42. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/index.html.erb +0 -135
  43. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +0 -143
  44. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/show.html.erb +0 -36
  45. data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +0 -94
  46. data/lib/active_agent/dashboard/config/routes.rb +0 -78
  47. data/lib/active_agent/dashboard/engine.rb +0 -39
  48. data/lib/active_agent/dashboard.rb +0 -151
  49. data/lib/generators/active_agent/dashboard/install/install_generator.rb +0 -96
  50. data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +0 -89
  51. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +0 -42
  52. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +0 -38
  53. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +0 -22
  54. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +0 -53
  55. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +0 -28
  56. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +0 -43
  57. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +0 -44
  58. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_telemetry_traces.rb +0 -56
  59. data/lib/generators/active_agent/dashboard/install_generator.rb +0 -64
  60. data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +0 -30
  61. data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +0 -30
@@ -2,212 +2,156 @@
2
2
 
3
3
  module ActiveAgent
4
4
  module Telemetry
5
- # Configuration for telemetry collection and reporting.
5
+ # The framework's telemetry configuration, now provided by the
6
+ # activeagents-telemetry gem. This subclass keeps the framework's
7
+ # historical behavior on top of the shared core:
6
8
  #
7
- # Stores settings for endpoint, authentication, sampling, and batching.
8
- # Configuration can be set programmatically or loaded from YAML.
9
+ # * telemetry is opt-in (`enabled` defaults to false)
10
+ # * `local_storage: true` persists traces through the dashboard's trace
11
+ # model instead of HTTP
9
12
  #
10
- # @example Programmatic configuration
11
- # ActiveAgent::Telemetry.configure do |config|
12
- # config.enabled = true
13
- # config.endpoint = "https://api.activeagents.ai/v1/traces"
14
- # config.api_key = "your-api-key"
15
- # config.sample_rate = 1.0
16
- # end
13
+ # Every other option — endpoint, api_key, sample_rate, batch_size,
14
+ # flush_interval, capture_bodies, redact_attributes, service_name,
15
+ # environment, resource_attributes — lives on the shared class, so
16
+ # existing config/active_agent.yml files keep working unchanged.
17
17
  #
18
- # @example YAML configuration (config/activeagent.yml)
19
- # telemetry:
20
- # enabled: true
21
- # endpoint: https://api.activeagents.ai/v1/traces
22
- # api_key: <%= ENV["ACTIVEAGENTS_API_KEY"] %>
23
- # sample_rate: 1.0
24
- # batch_size: 100
25
- # flush_interval: 5
26
- #
27
- class Configuration
28
- # @return [Boolean] Whether telemetry is enabled (default: false)
29
- attr_accessor :enabled
30
-
31
- # @return [String] The endpoint URL for sending traces
32
- attr_accessor :endpoint
33
-
34
- # @return [String] API key for authentication
35
- attr_accessor :api_key
36
-
37
- # @return [Float] Sampling rate from 0.0 to 1.0 (default: 1.0)
38
- attr_accessor :sample_rate
39
-
40
- # @return [Integer] Number of traces to batch before sending (default: 100)
41
- attr_accessor :batch_size
42
-
43
- # @return [Integer] Seconds between automatic flushes (default: 5)
44
- attr_accessor :flush_interval
45
-
46
- # @return [Integer] HTTP timeout in seconds (default: 10)
47
- attr_accessor :timeout
48
-
49
- # @return [Boolean] Whether to capture request/response bodies (default: false)
50
- attr_accessor :capture_bodies
51
-
52
- # @return [Array<String>] Attributes to redact from traces
53
- attr_accessor :redact_attributes
54
-
55
- # @return [String] Service name for trace attribution
56
- attr_accessor :service_name
57
-
58
- # @return [String] Environment name (development, staging, production)
59
- attr_accessor :environment
60
-
61
- # @return [Hash] Additional resource attributes to include in all traces
62
- attr_accessor :resource_attributes
63
-
64
- # @return [Logger] Logger for telemetry operations
65
- attr_accessor :logger
66
-
67
- # @return [Boolean] Whether to store traces locally in the app's database
68
- attr_accessor :local_storage
18
+ # @see ActiveAgents::Telemetry::Configuration
19
+ class Configuration < ActiveAgents::Telemetry::Configuration
20
+ # Fallback ingest path when the dashboard engine's mount point can't
21
+ # be resolved from the host's routes (e.g. engine not mounted).
22
+ LOCAL_ENDPOINT_PATH = "/activeagents/api/traces"
69
23
 
70
- # Default ActiveAgents.ai endpoint for hosted observability.
71
- DEFAULT_ENDPOINT = "https://api.activeagents.ai/v1/traces"
72
-
73
- # Local dashboard endpoint path (relative to app root)
74
- LOCAL_ENDPOINT_PATH = "/active_agent/api/traces"
24
+ # @return [Boolean] Whether to store traces in the app's own database
25
+ attr_reader :local_storage
75
26
 
76
27
  def initialize
77
- @enabled = false
78
- @endpoint = DEFAULT_ENDPOINT
79
- @api_key = nil
80
- @sample_rate = 1.0
81
- @batch_size = 100
82
- @flush_interval = 5
83
- @timeout = 10
84
- @capture_bodies = false
85
- @redact_attributes = %w[password secret token key credential api_key]
86
- @service_name = nil
87
- @environment = Rails.env if defined?(Rails)
88
- @resource_attributes = {}
89
- @logger = nil
28
+ super
29
+ # The framework predates the shared gem and has always been opt-in;
30
+ # adapters treat the presence of an api_key as the switch instead.
31
+ self.enabled = false
90
32
  @local_storage = false
91
33
  end
92
34
 
93
- # Returns whether telemetry collection is enabled.
94
- #
95
- # @return [Boolean]
96
- def enabled?
97
- @enabled == true
35
+ def local_storage=(value)
36
+ @local_storage = value == true
98
37
  end
99
38
 
100
- # Returns whether telemetry is properly configured.
101
- #
102
- # Checks that endpoint and api_key are present, or local_storage is enabled.
103
- #
104
- # @return [Boolean]
105
- def configured?
106
- local_storage? || (endpoint.present? && api_key.present?)
39
+ def local_storage?
40
+ @local_storage
107
41
  end
108
42
 
109
- # Returns whether local storage mode is enabled.
110
- #
111
- # @return [Boolean]
112
- def local_storage?
113
- @local_storage == true
43
+ def local_store?
44
+ local_storage? || super
45
+ end
46
+
47
+ # When local storage is on, traces persist through the dashboard's
48
+ # trace model rather than leaving the process.
49
+ def local_store
50
+ super || (dashboard_store if local_storage?)
114
51
  end
115
52
 
116
53
  # Returns the resolved endpoint for trace reporting.
117
- #
118
- # Uses local endpoint when local_storage is enabled.
119
- #
120
- # @return [String]
121
54
  def resolved_endpoint
122
- if local_storage?
123
- LOCAL_ENDPOINT_PATH
124
- else
125
- endpoint
126
- end
55
+ local_storage? ? local_endpoint_path : endpoint
127
56
  end
128
57
 
129
- # Returns whether a trace should be sampled.
130
- #
131
- # Uses sample_rate to determine if trace should be collected.
132
- #
133
- # @return [Boolean]
134
- def should_sample?
135
- return true if sample_rate >= 1.0
136
- return false if sample_rate <= 0.0
137
-
138
- rand < sample_rate
58
+ # The dashboard engine's ingest path, derived from wherever the host
59
+ # app actually mounted it — "/activeagents", "/observability", or "/"
60
+ # on a dedicated subdomain all work. Falls back to
61
+ # LOCAL_ENDPOINT_PATH when the engine isn't mounted.
62
+ def local_endpoint_path
63
+ mount = dashboard_mount_path
64
+ mount ? "#{mount}/api/traces" : LOCAL_ENDPOINT_PATH
139
65
  end
140
66
 
141
- # Resolves the service name for traces.
142
- #
143
- # Falls back to Rails application name or "activeagent".
144
- #
145
- # @return [String]
67
+ # The framework's historical fallback is "activeagent", not the shared
68
+ # core's generic "ruby".
146
69
  def resolved_service_name
147
- @service_name || rails_app_name || "activeagent"
70
+ value = super
71
+ value == "ruby" ? "activeagent" : value
148
72
  end
149
73
 
150
- # Returns the logger for telemetry operations.
151
- #
152
- # Falls back to Rails.logger or a null logger.
153
- #
154
- # @return [Logger]
155
- def resolved_logger
156
- @logger || (defined?(Rails) && Rails.logger) || Logger.new(File::NULL)
74
+ def to_h
75
+ super.merge(local_storage: local_storage)
157
76
  end
158
77
 
159
- # Loads configuration from a hash (typically from YAML).
160
- #
161
- # @param hash [Hash] Configuration hash
162
- # @return [self]
163
- def load_from_hash(hash)
164
- hash = hash.with_indifferent_access if hash.respond_to?(:with_indifferent_access)
165
-
166
- @enabled = hash[:enabled] if hash.key?(:enabled)
167
- @endpoint = hash[:endpoint] if hash.key?(:endpoint)
168
- @api_key = hash[:api_key] if hash.key?(:api_key)
169
- @sample_rate = hash[:sample_rate].to_f if hash.key?(:sample_rate)
170
- @batch_size = hash[:batch_size].to_i if hash.key?(:batch_size)
171
- @flush_interval = hash[:flush_interval].to_i if hash.key?(:flush_interval)
172
- @timeout = hash[:timeout].to_i if hash.key?(:timeout)
173
- @capture_bodies = hash[:capture_bodies] if hash.key?(:capture_bodies)
174
- @redact_attributes = hash[:redact_attributes] if hash.key?(:redact_attributes)
175
- @service_name = hash[:service_name] if hash.key?(:service_name)
176
- @environment = hash[:environment] if hash.key?(:environment)
177
- @resource_attributes = hash[:resource_attributes] if hash.key?(:resource_attributes)
178
- @local_storage = hash[:local_storage] if hash.key?(:local_storage)
179
-
180
- self
181
- end
78
+ private
182
79
 
183
- # Returns configuration as a hash for serialization.
80
+ # Persists a trace through the dashboard's trace model, honoring
81
+ # ActionAgent.trace_model_class overrides. Idempotent on trace_id, as
82
+ # HTTP ingest is.
184
83
  #
185
- # @return [Hash]
186
- def to_h
187
- {
188
- enabled: enabled,
189
- endpoint: endpoint,
190
- api_key: api_key ? "[REDACTED]" : nil,
191
- sample_rate: sample_rate,
192
- batch_size: batch_size,
193
- flush_interval: flush_interval,
194
- timeout: timeout,
195
- capture_bodies: capture_bodies,
196
- service_name: resolved_service_name,
197
- environment: environment,
198
- local_storage: local_storage
199
- }
84
+ # The dashboard is the `actionagent` gem, which the framework does not
85
+ # depend on — every reference to it here is guarded, so local_storage
86
+ # simply reports that it has nowhere to write when it isn't installed.
87
+ def dashboard_store
88
+ @dashboard_store ||= lambda do |trace, sdk|
89
+ model = local_trace_model
90
+ unless model
91
+ resolved_logger.error(
92
+ "[ActiveAgent::Telemetry] local_storage is enabled but no trace model is available — " \
93
+ "add the actionagent gem and run `rails generate action_agent:install` first"
94
+ )
95
+ next
96
+ end
97
+
98
+ next if model.exists?(trace_id: trace["trace_id"])
99
+
100
+ model.create_from_payload(trace, sdk)
101
+ end
200
102
  end
201
103
 
202
- private
104
+ def local_trace_model
105
+ if defined?(::ActionAgent) && ::ActionAgent.respond_to?(:trace_model)
106
+ ::ActionAgent.trace_model
107
+ elsif defined?(::ActionAgent::TelemetryTrace)
108
+ ::ActionAgent::TelemetryTrace
109
+ end
110
+ rescue NameError
111
+ nil
112
+ end
203
113
 
204
- def rails_app_name
205
- return nil unless defined?(Rails) && Rails.application
114
+ # The engine's mount point in the host app, found by locating the
115
+ # mounted engine in the host's route set. Works regardless of the
116
+ # helper name, so `mount ... => "/", as: :something_else` and
117
+ # constraint-wrapped mounts resolve correctly. Returns nil when the
118
+ # engine isn't mounted or no Rails app is booted; "" for a root mount.
119
+ def dashboard_mount_path
120
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
206
121
 
207
- Rails.application.class.module_parent_name.underscore
122
+ mount_path_in(::Rails.application.routes)
208
123
  rescue StandardError
209
124
  nil
210
125
  end
126
+
127
+ # The engine's mount path within a given route set, or nil when it
128
+ # isn't mounted there. Separate from #dashboard_mount_path so it can be
129
+ # exercised against a route set directly.
130
+ public def mount_path_in(route_set)
131
+ return nil unless defined?(::ActionAgent::Engine)
132
+
133
+ route = route_set.routes.find do |candidate|
134
+ mounted_engine(candidate.app) == ::ActionAgent::Engine
135
+ end
136
+ return nil unless route
137
+
138
+ # "/activeagents(.:format)" -> "/activeagents"; a root mount -> "".
139
+ route.path.spec.to_s.sub(/\(\.:format\)\z/, "").chomp("/")
140
+ end
141
+
142
+ # Unwraps the constraint layers Rails wraps a mounted engine in,
143
+ # returning the engine class (or nil for ordinary routes). Stops at the
144
+ # engine class itself — engines also respond to #app (their route set),
145
+ # so unwrapping blindly walks straight past them.
146
+ def mounted_engine(app)
147
+ 5.times do
148
+ return app if app.is_a?(Class) && app < ::Rails::Engine
149
+ break unless app.respond_to?(:app) && !app.app.equal?(app)
150
+
151
+ app = app.app
152
+ end
153
+ nil
154
+ end
211
155
  end
212
156
  end
213
157
  end
@@ -23,11 +23,6 @@ module ActiveAgent
23
23
  module Instrumentation
24
24
  extend ActiveSupport::Concern
25
25
 
26
- included do
27
- # Hook into generation lifecycle
28
- around_generate :trace_generation if respond_to?(:around_generate)
29
- end
30
-
31
26
  class_methods do
32
27
  # Installs instrumentation on the agent class.
33
28
  #
@@ -46,21 +41,82 @@ module ActiveAgent
46
41
  def process_prompt
47
42
  return super unless Telemetry.enabled?
48
43
 
49
- Telemetry.trace("#{self.class.name}.#{action_name}", span_type: :root) do |span|
44
+ # Reuse (or mint) the generation's trace id so the telemetry trace
45
+ # shares one id with everything else that reads
46
+ # prompt_options[:trace_id] — the generation request parameters and
47
+ # e.g. solid_agent's persisted generation records. Without this the
48
+ # tracer generates its own id and traces can't be correlated with
49
+ # the records they describe.
50
+ trace_id = nil
51
+ if respond_to?(:prompt_options) && prompt_options.is_a?(Hash)
52
+ trace_id = (prompt_options[:trace_id] ||= SecureRandom.uuid)
53
+ end
54
+
55
+ Telemetry.trace("#{self.class.name}.#{action_name}", span_type: :root, **{ trace_id: trace_id }.compact) do |span|
50
56
  span.set_attribute("agent.class", self.class.name)
51
57
  span.set_attribute("agent.action", action_name.to_s)
52
- span.set_attribute("agent.provider", provider_name) if respond_to?(:provider_name)
53
- span.set_attribute("agent.model", model_name) if respond_to?(:model_name)
58
+ span.set_attribute("agent.provider", provider_name)
59
+ span.set_attribute("agent.model", model_name)
54
60
 
55
- # Add prompt span
61
+ # Add prompt span, carrying the prompt contents (instructions +
62
+ # outbound messages) so dashboards can show what was sent.
56
63
  prompt_span = span.add_span("agent.prompt", span_type: :prompt)
57
- prompt_span.set_attribute("messages.count", messages.size) if respond_to?(:messages)
64
+ if (message_stack = prompt_options[:messages]).respond_to?(:size)
65
+ prompt_span.set_attribute("messages.count", message_stack.size)
66
+ end
67
+ # Attribute order is display order in dashboards: the system
68
+ # message first, then the tool roster, then the (often long)
69
+ # message history.
70
+ if prompt_options.is_a?(Hash)
71
+ rendered_instructions = begin
72
+ prompt_view_instructions(prompt_options[:instructions]) if respond_to?(:prompt_view_instructions)
73
+ rescue StandardError
74
+ prompt_options[:instructions].is_a?(String) ? prompt_options[:instructions] : nil
75
+ end
76
+ if rendered_instructions.present?
77
+ prompt_span.set_attribute("prompt.input.instructions", telemetry_truncate(Array(rendered_instructions).join("\n\n")))
78
+ end
79
+
80
+ if (tools = prompt_options[:tools]).present?
81
+ roster = Array(tools).filter_map { |tool|
82
+ next unless tool.is_a?(Hash)
83
+
84
+ name = tool[:name] || tool["name"]
85
+ description = tool[:description] || tool["description"]
86
+ parameters = tool[:parameters] || tool["parameters"] || tool[:input_schema] || tool["input_schema"]
87
+ properties = parameters.is_a?(Hash) ? (parameters[:properties] || parameters["properties"]) : nil
88
+ {
89
+ name: name,
90
+ description: telemetry_truncate(description),
91
+ parameters: properties.is_a?(Hash) ? properties.keys : []
92
+ }.compact
93
+ }
94
+ prompt_span.set_attribute("prompt.input.tools", JSON.generate(roster)) if roster.any?
95
+ end
96
+
97
+ if (outbound = prompt_options[:messages]).present?
98
+ serialized = Array(outbound).map { |message|
99
+ if message.is_a?(Hash)
100
+ role = message[:role] || message["role"] || "user"
101
+ content = message[:content] || message["content"]
102
+ { role: role.to_s, content: telemetry_truncate(content) }
103
+ else
104
+ { role: "user", content: telemetry_truncate(message) }
105
+ end
106
+ }
107
+ prompt_span.set_attribute("prompt.input.messages", JSON.generate(serialized))
108
+ end
109
+ end
58
110
  prompt_span.finish
59
111
 
60
112
  # Execute generation with LLM span
61
113
  llm_span = span.add_span("llm.generate", span_type: :llm)
62
- llm_span.set_attribute("llm.provider", provider_name) if respond_to?(:provider_name)
63
- llm_span.set_attribute("llm.model", model_name) if respond_to?(:model_name)
114
+ llm_span.set_attribute("llm.provider", provider_name)
115
+ llm_span.set_attribute("llm.model", model_name)
116
+
117
+ # Providers run tools through tools_function mid-generation; the
118
+ # wrapped proc (see below) hangs timed tool spans off this span.
119
+ @_telemetry_llm_span = llm_span
64
120
 
65
121
  begin
66
122
  result = super
@@ -91,10 +147,28 @@ module ActiveAgent
91
147
  tool_span = span.add_span("tool.#{tool_call[:name]}", span_type: :tool)
92
148
  tool_span.set_attribute("tool.name", tool_call[:name])
93
149
  tool_span.set_attribute("tool.id", tool_call[:id]) if tool_call[:id]
150
+ ToolOrigin.annotate(tool_span, tool_call[:name])
94
151
  tool_span.finish
95
152
  end
96
153
  end
97
154
 
155
+ # The span was tagged with the configured model (often unset →
156
+ # "unknown"); the provider's raw response knows what actually
157
+ # served the request.
158
+ if result.respond_to?(:raw_response) && result.raw_response.is_a?(Hash)
159
+ served_model = result.raw_response["model"] || result.raw_response[:model]
160
+ llm_span.set_attribute("llm.model", served_model.to_s) if served_model.present?
161
+ end
162
+
163
+ # Carry the generation contents so dashboards can show what
164
+ # came back, not just how many tokens it cost.
165
+ if result.respond_to?(:message) && result.message.respond_to?(:content) && result.message.content.present?
166
+ llm_span.set_attribute("llm.output.message", telemetry_truncate(result.message.content))
167
+ end
168
+ if result.respond_to?(:finish_reason) && result.finish_reason.present?
169
+ llm_span.set_attribute("llm.finish_reason", result.finish_reason.to_s)
170
+ end
171
+
98
172
  llm_span.set_status(:ok)
99
173
  llm_span.finish
100
174
  span.set_status(:ok)
@@ -105,6 +179,47 @@ module ActiveAgent
105
179
  llm_span.finish
106
180
  span.record_error(e)
107
181
  raise
182
+ ensure
183
+ @_telemetry_llm_span = nil
184
+ end
185
+ end
186
+ end
187
+
188
+ # Providers invoke this proc for every tool call during generation.
189
+ # Wrapping it is what makes tool telemetry real: each call gets a
190
+ # timed span with its arguments and result — the post-hoc
191
+ # result.tool_calls path below never fires on 1.x responses, which
192
+ # don't expose tool calls.
193
+ def tools_function
194
+ base = super
195
+ return base unless Telemetry.enabled?
196
+
197
+ agent = self
198
+ proc do |tool_name, *args, **kwargs|
199
+ parent = agent.instance_variable_get(:@_telemetry_llm_span)
200
+ unless parent
201
+ next base.call(tool_name, *args, **kwargs)
202
+ end
203
+
204
+ tool_span = parent.add_span("tool.#{tool_name}", span_type: :tool)
205
+ tool_span.set_attribute("tool.name", tool_name.to_s)
206
+ # Records which MCP server (if any) serves this tool, so tool
207
+ # traffic can be grouped by service downstream.
208
+ ToolOrigin.annotate(tool_span, tool_name)
209
+ arguments = kwargs.presence || (args.length == 1 ? args.first : args.presence)
210
+ if arguments.present?
211
+ tool_span.set_attribute("tool.input.args", agent.send(:telemetry_truncate, JSON.generate(arguments)))
212
+ end
213
+ begin
214
+ result = base.call(tool_name, *args, **kwargs)
215
+ tool_span.set_attribute("tool.output.result", agent.send(:telemetry_truncate, result))
216
+ tool_span.set_status(:ok)
217
+ result
218
+ rescue StandardError => e
219
+ tool_span.record_error(e)
220
+ raise
221
+ ensure
222
+ tool_span.finish
108
223
  end
109
224
  end
110
225
  end
@@ -116,7 +231,7 @@ module ActiveAgent
116
231
  Telemetry.trace("#{self.class.name}.embed", span_type: :embedding) do |span|
117
232
  span.set_attribute("agent.class", self.class.name)
118
233
  span.set_attribute("agent.action", "embed")
119
- span.set_attribute("agent.provider", provider_name) if respond_to?(:provider_name)
234
+ span.set_attribute("agent.provider", provider_name)
120
235
 
121
236
  begin
122
237
  result = super
@@ -138,8 +253,20 @@ module ActiveAgent
138
253
 
139
254
  private
140
255
 
256
+ # Content attributes are capped so a large prompt (e.g. a 100k-token
257
+ # tool loop) can't bloat the trace payload.
258
+ TELEMETRY_ATTRIBUTE_MAX_CHARS = 4_000
259
+
260
+ def telemetry_truncate(value)
261
+ text = value.to_s
262
+ return text if text.length <= TELEMETRY_ATTRIBUTE_MAX_CHARS
263
+
264
+ "#{text[0, TELEMETRY_ATTRIBUTE_MAX_CHARS]}… (truncated, #{text.length} chars total)"
265
+ end
266
+
141
267
  def provider_name
142
- self.class.generation_provider&.to_s || "unknown"
268
+ klass = prompt_provider_klass
269
+ klass.respond_to?(:tag_name) ? klass.tag_name : "unknown"
143
270
  rescue StandardError
144
271
  "unknown"
145
272
  end