activeagents-telemetry-ruby_llm 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5abf7d85a5deec9d008170f9c9a3e50d619024b7bad8c1f01eea3eda3d3befc6
4
- data.tar.gz: ea10cb9f3cb45700fe8ad0642c72eb3c6e01576c1e4353d274998ead5f3f7243
3
+ metadata.gz: e97d04ad7846320b7c233f247ce4ecad0ea2f20dba9574a73d2c3f93d3a47bfd
4
+ data.tar.gz: 50e301417298577aad66eb5d8423689d2c24742e5cc91be7a5fd55f518695c22
5
5
  SHA512:
6
- metadata.gz: 67e5b7d06a3de8510dead200b6ec9a4da44172d24b46c3ea02646cfd4fad1fc3c5acb960798e17a5957f61d8dcd4bd7efc392ae08f8e46ef19b084014e357073
7
- data.tar.gz: 16dde44b30870de36415b0958b85eabff49caf639238baf140107e6b2a986effe21bf0eea529c78b5810d90816098cac8c35f09800304c3c5cddd3c7ab6b3c22
6
+ metadata.gz: 759dd7ce43f5c74c4eef4003ec72e2254e618523a132fcd0e7d8c249416fb2c8a4d96170d2984d7a6c352ca95ff4240d1beff259d8d96b410807e02836660199
7
+ data.tar.gz: 2e49c0ec5f861e55fc42304ea30e3f27e5bcefe8043e841d54cb2bcb8bc39c95870793baa87d6128006ada7e5b8e12dc3aeeaa83ad5fd5d4b277c663b64e2d3c
data/README.md CHANGED
@@ -124,10 +124,66 @@ exceptions. A turn keeps the scope it started under, so a turn left open by a
124
124
  pending tool call and closed later by `flush!` still reports with this agent,
125
125
  attributes and callback, and a turn that started outside any scope never adopts
126
126
  one. Context
127
- is restored after the block, including when it raises, and nested scopes use
128
- their own attributes. A callback error is logged by exception class without
129
- dropping the trace. Attribute redaction and content-capture settings continue to
130
- apply.
127
+ is restored after the block, including when it raises. A callback error is
128
+ logged by exception class without dropping the trace. Attribute redaction and
129
+ content-capture settings continue to apply.
130
+
131
+ ### Nested scopes
132
+
133
+ Scopes compose, so an evaluation can wrap code that names its own agent
134
+ without losing what the evaluation attached. When a `with_agent` block opens
135
+ inside another one:
136
+
137
+ | Setting | Nested scope inside a plain scope | Nested scope inside a `pin: true` scope |
138
+ |---------|-----------------------------------|------------------------------------------|
139
+ | `attributes` | Merged: enclosing first, nested keys win on collision | Merged the same way |
140
+ | `on_trace` | Inherited unless the nested call passes its own | The pinned scope's callback stays in force |
141
+ | `synchronous` | Inherited unless the nested call passes its own (`nil`, the default, inherits; an explicit `false` overrides an inherited `true`) | The pinned scope's setting stays in force |
142
+ | `name` / `action` | Replaced by the nested scope | The pinned scope's identity stays in force |
143
+
144
+ A pinned scope owns the turn's identity: while it is active, a nested
145
+ `with_agent` may only add attributes, and it stays pinned however deep the
146
+ nesting goes. The previous scope is restored exactly on exit, including when
147
+ the block raises. The `agent.*` identity keys are always written last, so an
148
+ attribute with one of those names never overrides the reported agent.
149
+
150
+ ```ruby
151
+ ActiveAgents::Telemetry::RubyLLM.with_agent(
152
+ "SupportAgent", action: "replay",
153
+ attributes: { "eval.run_id" => run_id, "eval.result_id" => result_id },
154
+ on_trace: ->(trace) { result.trace_id = trace.trace_id },
155
+ synchronous: true, pin: true
156
+ ) do
157
+ # The code under test names its own scope around the actual call. The
158
+ # trace reports as SupportAgent.replay with eval.* and sparkle.role, the
159
+ # evaluation's on_trace receives it, and it is delivered before this
160
+ # block returns.
161
+ ActiveAgents::Telemetry::RubyLLM.with_agent("SupportAgent", action: "respond",
162
+ attributes: { "sparkle.role" => "assistant" }) do
163
+ chat.ask(prompt)
164
+ end
165
+ end
166
+ ```
167
+
168
+ ### Correlating with ActiveAgent evaluations
169
+
170
+ `ActiveAgent::Evals::Correlation` (activeagent >= 1.6.3) takes a `tracer:`
171
+ that it calls as `tracer.call(name, action:, attributes:, on_trace:) { ... }`
172
+ around each replay, and expects `on_trace` to receive something answering
173
+ `#trace_id`. `correlation_tracer` returns exactly that lambda, opening a
174
+ pinned, synchronous `with_agent` scope with the arguments it is given and
175
+ returning the block's value:
176
+
177
+ ```ruby
178
+ ActiveAgent::Evals::Correlation.new(
179
+ agent_name: "SupportAgent",
180
+ tracer: ActiveAgents::Telemetry::RubyLLM.correlation_tracer
181
+ )
182
+ ```
183
+
184
+ `correlation_tracer(synchronous: false)` or `correlation_tracer(pin: false)`
185
+ relaxes either default. This gem does not depend on activeagent; the only
186
+ contract shared with it is the lambda's signature.
131
187
 
132
188
  Report publication and trace ingestion are separate operations. Persist each run
133
189
  and result ID in the evaluation report and attach the same IDs to its response and
@@ -3,7 +3,7 @@
3
3
  module ActiveAgents
4
4
  module Telemetry
5
5
  module RubyLLM
6
- VERSION = "0.3.0"
6
+ VERSION = "0.3.2"
7
7
  end
8
8
  end
9
9
  end
@@ -43,6 +43,9 @@ module ActiveAgents
43
43
 
44
44
  DEFAULT_AGENT = { name: "RubyLLM::Chat", action: "chat" }.freeze
45
45
 
46
+ # Span token keys and the reader that answers each on a RubyLLM 2.x +Tokens+.
47
+ TOKEN_READERS = { "input" => :input, "output" => :output, "thinking" => :thinking }.freeze
48
+
46
49
  State = Struct.new(:depth, :started_at, :tool_spans, :rounds, :tokens, :chat_key, :agent)
47
50
 
48
51
  class << self
@@ -102,17 +105,71 @@ module ActiveAgents
102
105
  # `on_trace` runs only for a trace the reporter accepted, which means
103
106
  # it passed the enabled, configured and sampling checks: a delivery
104
107
  # that then fails is logged by the reporter, not announced here.
105
- def with_agent(name, action: "chat", attributes: {}, on_trace: nil, synchronous: false)
108
+ #
109
+ # Scopes compose. Inside an enclosing scope, a nested call merges its
110
+ # `attributes` onto the enclosing ones (a nested key wins on
111
+ # collision) and inherits `on_trace` and `synchronous` unless it
112
+ # passes its own; `synchronous: nil`, the default, means "inherit,
113
+ # else false", so an explicit `synchronous: false` still overrides an
114
+ # inherited `true`. Without `pin`, the nested scope replaces `name`
115
+ # and `action`, so a call site that names its own agent keeps doing
116
+ # so under an evaluation's scope. A scope opened with `pin: true` owns
117
+ # the turn's identity: while it is active a nested `with_agent` may
118
+ # only add attributes, and the pinned scope's `name`, `action`,
119
+ # `on_trace` and `synchronous` stay in force whatever the nested call
120
+ # passes. An evaluation replay pins its scope so the code under test,
121
+ # which opens its own scope around the actual `chat.ask`, cannot
122
+ # detach the trace from the evaluation's attributes, callback and
123
+ # synchronous delivery. The previous scope is restored on exit,
124
+ # including when the block raises.
125
+ #
126
+ # @param synchronous [Boolean, nil] nil inherits the enclosing scope's
127
+ # setting, or false at the top level.
128
+ # @param pin [Boolean] whether this scope keeps its identity, callback
129
+ # and delivery mode against nested scopes.
130
+ def with_agent(name, action: "chat", attributes: {}, on_trace: nil, synchronous: nil, pin: false)
106
131
  previous = Thread.current[AGENT_KEY]
107
- Thread.current[AGENT_KEY] = {
108
- name: name, action: action, attributes: attributes.to_h.transform_keys(&:to_s),
109
- on_trace: on_trace, synchronous: synchronous
110
- }
132
+ Thread.current[AGENT_KEY] = compose_scope(
133
+ previous, name: name, action: action, attributes: attributes,
134
+ on_trace: on_trace, synchronous: synchronous, pin: pin
135
+ )
111
136
  yield
112
137
  ensure
113
138
  Thread.current[AGENT_KEY] = previous
114
139
  end
115
140
 
141
+ # A `tracer:` for `ActiveAgent::Evals::Correlation` (activeagent >=
142
+ # 1.6.3), which wraps each evaluation replay in
143
+ # `tracer.call(name, action:, attributes:, on_trace:) { ... }` and
144
+ # expects `on_trace` to receive something answering `#trace_id`. The
145
+ # returned lambda opens a pinned, synchronous `with_agent` scope with
146
+ # exactly those arguments, so the replayed code's own `with_agent`
147
+ # around `chat.ask` can add attributes but cannot replace the
148
+ # evaluation's identity, drop its `on_trace` or turn delivery
149
+ # asynchronous, and the trace id the callback receives is the one
150
+ # the reporter delivered before the replay returns. The block's value
151
+ # is returned. There is no dependency on activeagent: the contract is
152
+ # only the lambda's signature.
153
+ #
154
+ # ActiveAgent::Evals::Correlation.new(
155
+ # agent_name: "SupportAgent",
156
+ # tracer: ActiveAgents::Telemetry::RubyLLM.correlation_tracer
157
+ # )
158
+ #
159
+ # @param synchronous [Boolean] whether the scope delivers in the
160
+ # calling thread; true keeps the trace id available when the
161
+ # replay returns and before a short-lived process exits.
162
+ # @param pin [Boolean] whether nested scopes may only add attributes.
163
+ # @return [Proc] `->(name, action:, attributes:, on_trace:, &block)`
164
+ def correlation_tracer(synchronous: true, pin: true)
165
+ lambda do |name, action: "chat", attributes: {}, on_trace: nil, &block|
166
+ with_agent(
167
+ name, action: action, attributes: attributes, on_trace: on_trace,
168
+ synchronous: synchronous, pin: pin, &block
169
+ )
170
+ end
171
+ end
172
+
116
173
  def state
117
174
  Thread.current[STATE_KEY] ||= State.new(0, nil, [], 0, Span::ZERO_TOKENS.dup, nil, nil)
118
175
  end
@@ -179,6 +236,27 @@ module ActiveAgents
179
236
 
180
237
  private
181
238
 
239
+ # The scope a nested `with_agent` installs, given the enclosing one.
240
+ # Attributes always merge with the nested keys winning; the rest
241
+ # depends on whether the enclosing scope is pinned.
242
+ def compose_scope(enclosing, name:, action:, attributes:, on_trace:, synchronous:, pin:)
243
+ own = attributes.to_h.transform_keys(&:to_s)
244
+ if enclosing.nil?
245
+ return { name: name, action: action, attributes: own, on_trace: on_trace,
246
+ synchronous: synchronous ? true : false, pin: pin ? true : false }
247
+ end
248
+
249
+ merged = (enclosing[:attributes] || {}).merge(own)
250
+ return enclosing.merge(attributes: merged) if enclosing[:pin]
251
+
252
+ {
253
+ name: name, action: action, attributes: merged,
254
+ on_trace: on_trace.nil? ? enclosing[:on_trace] : on_trace,
255
+ synchronous: synchronous.nil? ? enclosing[:synchronous] : synchronous,
256
+ pin: pin ? true : false
257
+ }
258
+ end
259
+
182
260
  def report_turn(payload, turn)
183
261
  agent = turn.agent || resolve_agent(payload) || DEFAULT_AGENT
184
262
  started_at = turn.started_at || Time.now
@@ -306,22 +384,39 @@ module ActiveAgents
306
384
  text.to_s[0, CONTENT_LIMIT]
307
385
  end
308
386
 
387
+ # Tokens for one round. RubyLLM 2.x puts the round's +Tokens+ on the
388
+ # payload and moves the per-message readers to +message.tokens+; 1.x
389
+ # has +input_tokens+ and friends on each assistant message and nothing
390
+ # on the payload. The payload is read first, then the messages the
391
+ # round added, with either generation's readers.
309
392
  def token_totals(payload)
393
+ tokens = payload_tokens(payload[:tokens]) || message_tokens(payload)
394
+ tokens["total"] = tokens.values.sum
395
+ tokens
396
+ end
397
+
398
+ def payload_tokens(tokens)
399
+ return unless TOKEN_READERS.values.all? { |reader| tokens.respond_to?(reader) }
400
+
401
+ TOKEN_READERS.to_h { |key, reader| [ key, tokens.public_send(reader).to_i ] }
402
+ end
403
+
404
+ def message_tokens(payload)
310
405
  initial_count = Array(payload[:input_messages]).size
311
406
  new_messages = Array(payload[:messages_after])[initial_count..] || []
312
407
  assistant_messages = new_messages.select { |message| message.respond_to?(:role) && message.role.to_s == "assistant" }
313
408
 
314
- tokens = {
315
- "input" => sum_tokens(assistant_messages, :input_tokens),
316
- "output" => sum_tokens(assistant_messages, :output_tokens),
317
- "thinking" => sum_tokens(assistant_messages, :thinking_tokens)
318
- }
319
- tokens["total"] = tokens.values.sum
320
- tokens
409
+ TOKEN_READERS.to_h { |key, reader| [ key, assistant_messages.sum { |message| message_token_count(message, key, reader) } ] }
321
410
  end
322
411
 
323
- def sum_tokens(messages, method_name)
324
- messages.sum { |message| message.respond_to?(method_name) ? message.public_send(method_name).to_i : 0 }
412
+ # A 1.x message answers +input_tokens+; a 2.x message answers
413
+ # +tokens.input+.
414
+ def message_token_count(message, key, reader)
415
+ legacy_reader = :"#{key}_tokens"
416
+ return message.public_send(legacy_reader).to_i if message.respond_to?(legacy_reader)
417
+
418
+ tokens = message.tokens if message.respond_to?(:tokens)
419
+ tokens.respond_to?(reader) ? tokens.public_send(reader).to_i : 0
325
420
  end
326
421
  end
327
422
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeagents-telemetry-ruby_llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - ActiveAgents
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-13 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activeagents-telemetry