riffer 0.34.0 → 0.35.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc442fb9eb0296a5a9c6f289c1c9e63e7cdb732ef52b013e1556d1765a874f2d
4
- data.tar.gz: aa7512f0b3ac38e4f0c7be500095c73fca0ae1740c37370ceb2aeaf57349dac8
3
+ metadata.gz: acfc9a0c002c97b79d515172d71aa419359e27fac0a3809934c30057931e66ef
4
+ data.tar.gz: 322aaebffb66c41824fdd3a3c43d08ae6bb806f8e2e06ca5ee1834648704e1d5
5
5
  SHA512:
6
- metadata.gz: d9ace157326fa9ebfb356705f14cd4a8bac4af86e3ede2eb142adb4be36870399c81ee122fa02973135c9176b521bdee980745e3cc3f14422e12fbf1392fb009
7
- data.tar.gz: cdc2e19b1aab77a93fb6ad7c4a800c5002a5efb63667366113fc04104a05c342863a1fa74219ed2569759f5f7be08dc534ac7c79b10c6d9ab61d6430db805113
6
+ metadata.gz: ba3554a4a135ae43f5d5a2d65928275e4acfcc27c03609f2578d5304e8825c3f42f05b7229fe5881fdc7c663372ef6ca3591af02b2719167bc7711fc7754e7e6
7
+ data.tar.gz: 49c2b41f0cb606ec9e86b72777d55b3b3222220d879b5e05ed211a07094d9561c7e1c054ce2689d3b81b6ff872d981f6e1f24306b916cf12a697f7df23f91a69
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.34.0"
2
+ ".": "0.35.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.35.0](https://github.com/janeapp/riffer/compare/riffer/v0.34.0...riffer/v0.35.0) (2026-06-25)
9
+
10
+
11
+ ### Features
12
+
13
+ * per-call tags: for Agent#generate and #stream ([#339](https://github.com/janeapp/riffer/issues/339)) ([ddfda89](https://github.com/janeapp/riffer/commit/ddfda8929ca3b594415f827957d8c91104387317))
14
+
8
15
  ## [0.34.0](https://github.com/janeapp/riffer/compare/riffer/v0.33.0...riffer/v0.34.0) (2026-06-22)
9
16
 
10
17
 
data/docs/03_AGENTS.md CHANGED
@@ -317,6 +317,50 @@ agent.generate('Hello')
317
317
 
318
318
  When `config:` is supplied, the class-level configuration is ignored for that instance.
319
319
 
320
+ ## Per-Call Tags
321
+
322
+ `#generate` and `#stream` accept an optional `tags:` hash — a flat map of attribution labels scoped to that single call (for cost/usage attribution, filtering audit logs, slicing telemetry). It is **per-call only**.
323
+
324
+ ```ruby
325
+ agent.generate("Summarize this ticket.",
326
+ tags: {team: "growth", feature: "summarizer", user_id: "u_abc123"})
327
+
328
+ agent.stream("...", tags: {team: "growth", environment: "production"})
329
+ ```
330
+
331
+ Keys and values may be `String` or `Symbol`; both are stringified, and entries with a `nil` value are dropped. Passing a non-`Hash` raises `Riffer::ArgumentError`. An omitted or empty `tags:` is a complete no-op.
332
+
333
+ Tags propagate to **two** places:
334
+
335
+ 1. The provider's native per-request metadata field (see the mapping below).
336
+ 2. Observability — stamped as `riffer.tag.<key>` on **every** span and metric the call emits (`invoke_agent`, `chat`, `execute_tool`, `execute_guardrail` spans, and the duration/token-usage/cost metrics). See [Tracing](16_TRACING.md) and [Metrics](17_METRICS.md).
337
+
338
+ ### Reserved key: `user_id`
339
+
340
+ `user_id` is a reserved tag. Beyond appearing like any other tag, it maps to the provider's native end-user identifier where one exists (see the table).
341
+
342
+ ### Provider mapping
343
+
344
+ | Provider | Native request field | `user_id` handling |
345
+ | --------------------- | ----------------------------- | --------------------------------------- |
346
+ | Amazon Bedrock | `request_metadata` (all tags) | Ordinary entry; no dedicated user field |
347
+ | OpenAI / Azure OpenAI | `metadata` (all tags) | Also mapped to `safety_identifier` |
348
+ | OpenRouter | `metadata` (all tags) | Also mapped to `user` |
349
+ | Anthropic | `metadata.user_id` **only** | The only tag forwarded |
350
+ | Gemini | _(none — observability only)_ | Tag only; no request field |
351
+
352
+ **Anthropic silently drops non-`user_id` tags.** The Messages API has no free-form request-metadata field — only `metadata.user_id`. So for Anthropic, `user_id` is forwarded as `metadata: {user_id: …}` and **every other tag is dropped from the request** (it still appears on spans and metrics). This is intentional.
353
+
354
+ **Gemini is observability-only.** Riffer's Gemini adapter targets the Gemini Developer API (`generativelanguage.googleapis.com`), whose `generateContent` request has **no** `labels` field — sending unknown fields is rejected. So tags are **not** added to the Gemini request; they propagate to spans and metrics only. Native request labels (`labels`, lowercase `[a-z0-9_-]`, ≤63 chars each) are a Vertex AI feature and would arrive with a future Vertex adapter.
355
+
356
+ ### Provider limits (not enforced)
357
+
358
+ Riffer does not validate tag count, key/value length, or charset — it forwards what you give it and lets the provider reject anything out of bounds. Known limits: Bedrock `requestMetadata` ≤16 entries, key/value ≤256 chars; OpenAI / OpenRouter `metadata` ≤16 pairs (OpenRouter: 64-char keys, 512-char values).
359
+
360
+ ### Metric cardinality
361
+
362
+ Tags are promoted onto metric dimensions verbatim — there is no allow-list. High-cardinality values (e.g. a unique `user_id` per request) will create a correspondingly large number of metric series in your backend. Keep metric-bound tags low-cardinality.
363
+
320
364
  ## Expand Your Agent
321
365
 
322
366
  | Goal | Feature | Guide |
data/docs/16_TRACING.md CHANGED
@@ -93,6 +93,10 @@ Every attribute a span can carry is listed below, including the conditional ones
93
93
 
94
94
  The contract promise is: **when present**, a key carries the documented meaning and type. It is _not_ a promise that every key appears on every span.
95
95
 
96
+ ### Per-call tags (`riffer.tag.*`)
97
+
98
+ Any tags passed to `#generate` / `#stream` via `tags:` are stamped on **all four** span types as `riffer.tag.<key>` (string), so the per-span tables below omit them. They appear on every span the tagged call emits and are absent otherwise. Example: `tags: {team: "growth"}` adds `riffer.tag.team` → `"growth"` to the `invoke_agent`, `chat`, `execute_tool`, and `execute_guardrail` spans. See [Per-Call Tags](03_AGENTS.md#per-call-tags) for the full surface and the per-provider request-metadata mapping.
99
+
96
100
  ## `invoke_agent {agent}` — the run span
97
101
 
98
102
  `INTERNAL`. One per call to `Agent#generate` or `Agent#stream`. The span name suffix is the agent's identifier (e.g. `invoke_agent weather-agent`).
data/docs/17_METRICS.md CHANGED
@@ -74,6 +74,8 @@ OpenTelemetry.meter_provider.add_view(
74
74
 
75
75
  Each instrument is documented here as a row carrying its name, instrument type, unit, and attribute set.
76
76
 
77
+ > **Per-call tags become dimensions.** Any tags passed to `#generate` / `#stream` via `tags:` are added as `riffer.tag.<key>` attributes to **every** instrument below — `operation.duration` (each operation), `token.usage` (each point), `cost`, and `guardrail.duration` — so the attribute lists omit them. Tags are promoted verbatim with no allow-list — a high-cardinality value like a unique `user_id` per request multiplies time series accordingly; drop it with a [View](https://opentelemetry.io/docs/specs/otel/metrics/sdk/#view) or keep metric-bound tags low-cardinality. See [Per-Call Tags](03_AGENTS.md#per-call-tags).
78
+
77
79
  ### `gen_ai.client.operation.duration`
78
80
 
79
81
  Histogram, unit `s`. The latency of a single GenAI operation, recorded around the same wrap as the matching [span](16_TRACING.md) on both the success and error paths and timed with a monotonic clock. Recording is independent of tracing — the metric fires even with `config.tracing.enabled = false`. Tell the three operations apart by `gen_ai.operation.name`.
@@ -123,9 +125,9 @@ Histogram, unit `s`. The latency of a single guardrail execution, recorded aroun
123
125
 
124
126
  This instrument is Riffer-owned (`riffer.*`, not `gen_ai.*`) by the same reasoning as its span: a guardrail is not a GenAI semantic-convention operation, so the `execute_guardrail` span deliberately carries no `gen_ai.operation.name` and lives in riffer's own `riffer.guardrail.*` namespace (see [Tracing](16_TRACING.md)). Folding the metric into `gen_ai.client.operation.duration` would contradict that, so the duration is its own riffer-owned histogram instead; see [Stability](#stability).
125
127
 
126
- | Value | Attributes |
127
- | ------------------------------------ | --------------------------------------------------------------------- |
128
- | duration of the guardrail execution | `riffer.guardrail.name`, `riffer.guardrail.phase`, `error.type` (on raise) |
128
+ | Value | Attributes |
129
+ | ----------------------------------- | -------------------------------------------------------------------------- |
130
+ | duration of the guardrail execution | `riffer.guardrail.name`, `riffer.guardrail.phase`, `error.type` (on raise) |
129
131
 
130
132
  `riffer.guardrail.phase` is `before` or `after`. A pass, transform, or block is a **handled** outcome and records no `error.type` — that attribute carries the exception class only when a guardrail raises, mirroring the span. One time series exists per `riffer.guardrail.name` × `riffer.guardrail.phase`; guardrail names are bounded by the guardrails you configure, so cardinality is safe — unlike the dynamic tool names on `gen_ai.client.operation.duration`.
131
133
 
@@ -10,37 +10,45 @@ module Riffer::Agent::Run
10
10
  # for prompt/files semantics.
11
11
  #
12
12
  #--
13
- #: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Riffer::Agent::Response
14
- def generate(agent:, prompt: nil, files: nil)
13
+ #: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Riffer::Agent::Response
14
+ def generate(agent:, prompt: nil, files: nil, tags: {})
15
15
  append_user_message(agent, prompt, files: files)
16
- run_loop(agent)
16
+ run_loop(agent, tags: tags)
17
17
  end
18
18
 
19
19
  # Runs the streaming loop for the given agent. See Riffer::Agent#stream
20
20
  # for prompt/files semantics.
21
21
  #
22
22
  #--
23
- #: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
24
- def stream(agent:, prompt: nil, files: nil)
23
+ #: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
24
+ def stream(agent:, prompt: nil, files: nil, tags: {})
25
25
  append_user_message(agent, prompt, files: files)
26
26
  # The enumerator body runs in its own fiber, where the fiber-local OTEL
27
- # context is empty — capture here so the run span parents to the caller's trace.
27
+ # context is empty — capture here so the run span parents to the caller's
28
+ # trace. tags ride as an ordinary argument captured in the closure, so they
29
+ # cross the fiber boundary without any re-propagation.
28
30
  trace_context = Riffer::Tracing.current_context
29
31
  Enumerator.new do |stream_yielder|
30
- Riffer::Tracing.with_context(trace_context) { run_loop(agent, stream_yielder: stream_yielder) }
32
+ Riffer::Tracing.with_context(trace_context) { run_loop(agent, tags: tags, stream_yielder: stream_yielder) }
31
33
  end
32
34
  end
33
35
 
34
36
  private
35
37
 
38
+ # Both +generate+ and +stream+ funnel here, so this is the single place raw
39
+ # +tags+ are normalized. The clean <tt>String => String</tt> map is then
40
+ # threaded to every span/metric builder in the run as +riffer.tag.*+ and to
41
+ # each provider call (via +merged_model_options+) for native request-metadata
42
+ # mapping.
36
43
  #--
37
- #: (Riffer::Agent, ?stream_yielder: Enumerator::Yielder?) -> Riffer::Agent::Response
38
- def run_loop(agent, stream_yielder: nil)
44
+ #: (Riffer::Agent, ?tags: Hash[(String | Symbol), untyped]?, ?stream_yielder: Enumerator::Yielder?) -> Riffer::Agent::Response
45
+ def run_loop(agent, tags: {}, stream_yielder: nil)
46
+ tags = normalize_tags(tags)
39
47
  start = Riffer::Metrics.monotonic_now
40
48
  error_type = nil #: String?
41
49
  begin
42
- Riffer::Tracing.in_span("invoke_agent #{agent.class.identifier}", attributes: run_span_attributes(agent), kind: :internal) do |span|
43
- response = execute_run(agent, stream_yielder)
50
+ Riffer::Tracing.in_span("invoke_agent #{agent.class.identifier}", attributes: run_span_attributes(agent, tags), kind: :internal) do |span|
51
+ response = execute_run(agent, stream_yielder, tags)
44
52
  record_run_outcome(span, response)
45
53
  response
46
54
  rescue => error
@@ -55,18 +63,18 @@ module Riffer::Agent::Run
55
63
  error_type = error.class.name #: String?
56
64
  raise
57
65
  ensure
58
- Riffer::Metrics::Instruments::OPERATION_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: run_metric_attributes(agent, error_type))
66
+ Riffer::Metrics::Instruments::OPERATION_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: run_metric_attributes(agent, error_type, tags))
59
67
  end
60
68
  end
61
69
 
62
70
  #--
63
- #: (Riffer::Agent, Enumerator::Yielder?) -> Riffer::Agent::Response
64
- def execute_run(agent, stream_yielder)
71
+ #: (Riffer::Agent, Enumerator::Yielder?, ?Hash[String, String]) -> Riffer::Agent::Response
72
+ def execute_run(agent, stream_yielder, tags = {})
65
73
  all_modifications = [] #: Array[Riffer::Guardrails::Modification]
66
74
  run_usage = nil #: Riffer::Providers::TokenUsage?
67
75
  run_steps = 0
68
76
 
69
- run_before_guardrails(agent, stream_yielder, all_modifications) do |tripwire|
77
+ run_before_guardrails(agent, stream_yielder, all_modifications, tags) do |tripwire|
70
78
  return tripwire_response(agent, stream_yielder, tripwire, all_modifications, steps: run_steps)
71
79
  end
72
80
 
@@ -84,16 +92,16 @@ module Riffer::Agent::Run
84
92
  step = agent.session.steps
85
93
 
86
94
  reason = catch(:riffer_interrupt) do
87
- execute_pending_tool_calls(agent)
95
+ execute_pending_tool_calls(agent, tags)
88
96
 
89
97
  loop do
90
- response = stream_yielder ? accumulate_streamed_response(agent, stream_yielder) : call_llm(agent)
98
+ response = stream_yielder ? accumulate_streamed_response(agent, stream_yielder, tags) : call_llm(agent, tags)
91
99
  step += 1
92
100
  run_steps += 1
93
101
  track_token_usage(agent, response.token_usage)
94
102
  run_usage = sum_usage(run_usage, response.token_usage)
95
103
 
96
- processed_response = run_after_guardrails(agent, response, stream_yielder, all_modifications) do |tripwire|
104
+ processed_response = run_after_guardrails(agent, response, stream_yielder, all_modifications, tags) do |tripwire|
97
105
  return tripwire_response(agent, stream_yielder, tripwire, all_modifications, token_usage: run_usage, steps: run_steps)
98
106
  end
99
107
 
@@ -104,7 +112,7 @@ module Riffer::Agent::Run
104
112
  max_steps = agent.config.max_steps
105
113
  throw :riffer_interrupt, Riffer::Agent::INTERRUPT_MAX_STEPS if max_steps && step >= max_steps
106
114
 
107
- execute_tool_calls(agent, processed_response)
115
+ execute_tool_calls(agent, processed_response, tags: tags)
108
116
  end
109
117
 
110
118
  return final_response(agent, all_modifications, token_usage: run_usage, steps: run_steps)
@@ -122,14 +130,14 @@ module Riffer::Agent::Run
122
130
  end
123
131
 
124
132
  #--
125
- #: (Riffer::Agent, Enumerator::Yielder) -> Riffer::Messages::Assistant
126
- def accumulate_streamed_response(agent, stream_yielder)
133
+ #: (Riffer::Agent, Enumerator::Yielder, ?Hash[String, String]) -> Riffer::Messages::Assistant
134
+ def accumulate_streamed_response(agent, stream_yielder, tags = {})
127
135
  accumulated_content = ""
128
136
  accumulated_tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
129
137
  accumulated_token_usage = nil #: Riffer::Providers::TokenUsage?
130
138
  accumulated_finish_reason = nil #: Symbol?
131
139
 
132
- call_llm_stream(agent).each do |event|
140
+ call_llm_stream(agent, tags).each do |event|
133
141
  stream_yielder << event
134
142
 
135
143
  case event
@@ -180,33 +188,33 @@ module Riffer::Agent::Run
180
188
  end
181
189
 
182
190
  #--
183
- #: (Riffer::Agent) -> Riffer::Messages::Assistant
184
- def call_llm(agent)
191
+ #: (Riffer::Agent, ?Hash[String, String]) -> Riffer::Messages::Assistant
192
+ def call_llm(agent, tags = {})
185
193
  agent.provider.generate_text(
186
194
  messages: agent.session.messages,
187
195
  model: agent.model_name,
188
196
  tools: effective_tools(agent),
189
- **merged_model_options(agent)
197
+ **merged_model_options(agent, tags)
190
198
  )
191
199
  end
192
200
 
193
201
  #--
194
- #: (Riffer::Agent) -> Enumerator[Riffer::StreamEvents::Base, void]
195
- def call_llm_stream(agent)
202
+ #: (Riffer::Agent, ?Hash[String, String]) -> Enumerator[Riffer::StreamEvents::Base, void]
203
+ def call_llm_stream(agent, tags = {})
196
204
  agent.provider.stream_text(
197
205
  messages: agent.session.messages,
198
206
  model: agent.model_name,
199
207
  tools: effective_tools(agent),
200
- **merged_model_options(agent)
208
+ **merged_model_options(agent, tags)
201
209
  )
202
210
  end
203
211
 
204
212
  #--
205
- #: (Riffer::Agent, Riffer::Messages::Assistant, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall]) -> void
206
- def execute_tool_calls(agent, assistant_message, tool_calls: assistant_message.tool_calls)
213
+ #: (Riffer::Agent, Riffer::Messages::Assistant, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?tags: Hash[String, String]) -> void
214
+ def execute_tool_calls(agent, assistant_message, tool_calls: assistant_message.tool_calls, tags: {})
207
215
  return if tool_calls.empty?
208
216
 
209
- results = agent.tool_runtime.execute(tool_calls, tools: effective_tools(agent), context: agent.context, assistant_message: assistant_message)
217
+ results = agent.tool_runtime.execute(tool_calls, tools: effective_tools(agent), context: agent.context, assistant_message: assistant_message, tags: tags)
210
218
 
211
219
  inject_discovered_tools(agent, results)
212
220
 
@@ -233,19 +241,19 @@ module Riffer::Agent::Run
233
241
  end
234
242
 
235
243
  #--
236
- #: (Riffer::Agent) -> void
237
- def execute_pending_tool_calls(agent)
244
+ #: (Riffer::Agent, ?Hash[String, String]) -> void
245
+ def execute_pending_tool_calls(agent, tags = {})
238
246
  assistant_message, pending = agent.session.pending_tool_calls
239
- execute_tool_calls(agent, assistant_message, tool_calls: pending) if assistant_message
247
+ execute_tool_calls(agent, assistant_message, tool_calls: pending, tags: tags) if assistant_message
240
248
  end
241
249
 
242
250
  #--
243
- #: (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Guardrails::Tripwire) -> void } -> void
244
- def run_before_guardrails(agent, stream_yielder, all_modifications)
251
+ #: (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], ?Hash[String, String]) { (Riffer::Guardrails::Tripwire) -> void } -> void
252
+ def run_before_guardrails(agent, stream_yielder, all_modifications, tags = {})
245
253
  guardrails = agent.config.guardrails_for(:before)
246
254
  return if guardrails.empty?
247
255
 
248
- runner = Riffer::Guardrails::Runner.new(guardrails, phase: :before, context: agent.context)
256
+ runner = Riffer::Guardrails::Runner.new(guardrails, phase: :before, context: agent.context, tags: tags)
249
257
  processed_messages, tripwire, modifications = runner.run(agent.session.messages)
250
258
  agent.session.set(processed_messages) unless tripwire
251
259
  record_modifications!(stream_yielder, all_modifications, modifications)
@@ -253,12 +261,12 @@ module Riffer::Agent::Run
253
261
  end
254
262
 
255
263
  #--
256
- #: (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Guardrails::Tripwire) -> void } -> untyped
257
- def run_after_guardrails(agent, response, stream_yielder, all_modifications)
264
+ #: (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], ?Hash[String, String]) { (Riffer::Guardrails::Tripwire) -> void } -> untyped
265
+ def run_after_guardrails(agent, response, stream_yielder, all_modifications, tags = {})
258
266
  guardrails = agent.config.guardrails_for(:after)
259
267
  return response if guardrails.empty?
260
268
 
261
- runner = Riffer::Guardrails::Runner.new(guardrails, phase: :after, context: agent.context)
269
+ runner = Riffer::Guardrails::Runner.new(guardrails, phase: :after, context: agent.context, tags: tags)
262
270
  processed_response, tripwire, modifications = runner.run(response, messages: agent.session.messages)
263
271
 
264
272
  response_index = agent.session.messages.length
@@ -285,11 +293,16 @@ module Riffer::Agent::Run
285
293
  discovered.empty? ? agent.tools : agent.tools + discovered
286
294
  end
287
295
 
296
+ # +tags+ rides in the options hash as a curated key the providers extract for
297
+ # native request-metadata mapping (alongside +:structured_output+); it never
298
+ # reaches an SDK call verbatim. Span/metric tagging is threaded separately to
299
+ # each builder.
288
300
  #--
289
- #: (Riffer::Agent) -> Hash[Symbol, untyped]
290
- def merged_model_options(agent)
301
+ #: (Riffer::Agent, ?Hash[String, String]) -> Hash[Symbol, untyped]
302
+ def merged_model_options(agent, tags = {})
291
303
  opts = agent.config.model_options.dup
292
304
  opts[:structured_output] = agent.structured_output if agent.structured_output
305
+ opts[:tags] = tags unless tags.empty?
293
306
  opts
294
307
  end
295
308
 
@@ -329,19 +342,19 @@ module Riffer::Agent::Run
329
342
  end
330
343
 
331
344
  #--
332
- #: (Riffer::Agent) -> Hash[String, untyped]
333
- def run_span_attributes(agent)
345
+ #: (Riffer::Agent, ?Hash[String, String]) -> Hash[String, untyped]
346
+ def run_span_attributes(agent, tags = {})
334
347
  {
335
348
  "gen_ai.operation.name" => "invoke_agent",
336
349
  "gen_ai.agent.name" => agent.class.identifier,
337
350
  "gen_ai.provider.name" => agent.provider.class.semconv_provider_name,
338
351
  "gen_ai.request.model" => agent.model_name
339
- }
352
+ }.merge(tag_attributes(tags))
340
353
  end
341
354
 
342
355
  #--
343
- #: (Riffer::Agent, String?) -> Hash[String, untyped]
344
- def run_metric_attributes(agent, error_type)
356
+ #: (Riffer::Agent, String?, ?Hash[String, String]) -> Hash[String, untyped]
357
+ def run_metric_attributes(agent, error_type, tags = {})
345
358
  attributes = {
346
359
  "gen_ai.operation.name" => "invoke_agent",
347
360
  "gen_ai.provider.name" => agent.provider.class.semconv_provider_name,
@@ -349,7 +362,27 @@ module Riffer::Agent::Run
349
362
  "gen_ai.agent.name" => agent.class.identifier
350
363
  } #: Hash[String, untyped]
351
364
  attributes["error.type"] = error_type if error_type
352
- attributes
365
+ attributes.merge(tag_attributes(tags))
366
+ end
367
+
368
+ #--
369
+ #: (Hash[(String | Symbol), untyped]?) -> Hash[String, String]
370
+ def normalize_tags(tags)
371
+ return {} if tags.nil?
372
+ raise Riffer::ArgumentError, "tags: must be a Hash, got #{tags.class}" unless tags.is_a?(Hash)
373
+
374
+ result = {} #: Hash[String, String]
375
+ tags.each do |key, value|
376
+ next if value.nil?
377
+ result[key.to_s] = value.to_s
378
+ end
379
+ result
380
+ end
381
+
382
+ #--
383
+ #: (Hash[String, String]) -> Hash[String, String]
384
+ def tag_attributes(tags)
385
+ tags.transform_keys { |key| "riffer.tag.#{key}" }
353
386
  end
354
387
 
355
388
  #--
data/lib/riffer/agent.rb CHANGED
@@ -169,16 +169,16 @@ class Riffer::Agent
169
169
 
170
170
  # Generates a response using a new agent instance.
171
171
  #--
172
- #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
173
- def self.generate(prompt = nil, files: nil, context: nil)
174
- new(context: context).generate(prompt, files: files)
172
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[(String | Symbol), untyped]) -> Riffer::Agent::Response
173
+ def self.generate(prompt = nil, files: nil, context: nil, tags: {})
174
+ new(context: context).generate(prompt, files: files, tags: tags)
175
175
  end
176
176
 
177
177
  # Streams a response using a new agent instance.
178
178
  #--
179
- #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Enumerator[Riffer::StreamEvents::Base, void]
180
- def self.stream(prompt = nil, files: nil, context: nil)
181
- new(context: context).stream(prompt, files: files)
179
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
180
+ def self.stream(prompt = nil, files: nil, context: nil, tags: {})
181
+ new(context: context).stream(prompt, files: files, tags: tags)
182
182
  end
183
183
 
184
184
  # Reconstructs a runnable agent from a wire hash produced by +#to_h+.
@@ -288,22 +288,29 @@ class Riffer::Agent
288
288
  # against the current session, resuming a persisted conversation or pending
289
289
  # tool calls. +files:+ requires +prompt+.
290
290
  #
291
+ # +tags:+ is an optional flat hash of attribution tags applied to this single
292
+ # call: they propagate to the provider's native request-metadata field, and
293
+ # are stamped as +riffer.tag.*+ on every span and metric the call emits. See
294
+ # +docs/03_AGENTS.md+ for the per-provider mapping. The reserved key
295
+ # +user_id+ also maps to the provider's native user identifier where one
296
+ # exists.
297
+ #
291
298
  #--
292
- #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Riffer::Agent::Response
293
- def generate(prompt = nil, files: nil)
294
- Riffer::Agent::Run.generate(agent: self, prompt: prompt, files: files)
299
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Riffer::Agent::Response
300
+ def generate(prompt = nil, files: nil, tags: {})
301
+ Riffer::Agent::Run.generate(agent: self, prompt: prompt, files: files, tags: tags)
295
302
  end
296
303
 
297
304
  # Streams a response from the agent, returning an +Enumerator+ of
298
- # +Riffer::StreamEvents+. See +#generate+ for prompt/files semantics.
305
+ # +Riffer::StreamEvents+. See +#generate+ for prompt/files/tags semantics.
299
306
  #
300
307
  # Raises Riffer::ArgumentError if structured output is configured.
301
308
  #
302
309
  #--
303
- #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
304
- def stream(prompt = nil, files: nil)
310
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
311
+ def stream(prompt = nil, files: nil, tags: {})
305
312
  raise Riffer::ArgumentError, "Structured output is not supported with streaming. Use #generate instead." if @structured_output
306
- Riffer::Agent::Run.stream(agent: self, prompt: prompt, files: files)
313
+ Riffer::Agent::Run.stream(agent: self, prompt: prompt, files: files, tags: tags)
307
314
  end
308
315
 
309
316
  # Interrupts the agent loop from an +on_message+ callback. Equivalent to
@@ -13,12 +13,16 @@ class Riffer::Guardrails::Runner
13
13
  # The context passed to guardrails.
14
14
  attr_reader :context #: untyped
15
15
 
16
+ # The normalized per-call tags, stamped as +riffer.tag.*+ on guardrail spans.
17
+ attr_reader :tags #: Hash[String, String]
18
+
16
19
  #--
17
- #: (Array[Hash[Symbol, untyped]], phase: Symbol, ?context: untyped) -> void
18
- def initialize(guardrail_configs, phase:, context: nil)
20
+ #: (Array[Hash[Symbol, untyped]], phase: Symbol, ?context: untyped, ?tags: Hash[String, String]) -> void
21
+ def initialize(guardrail_configs, phase:, context: nil, tags: {})
19
22
  @guardrail_configs = guardrail_configs
20
23
  @phase = phase
21
24
  @context = context
25
+ @tags = tags
22
26
  end
23
27
 
24
28
  # Runs the guardrails sequentially. For the +:before+ phase +data+ is the
@@ -129,7 +133,7 @@ class Riffer::Guardrails::Runner
129
133
  {
130
134
  "riffer.guardrail.name" => guardrail.name,
131
135
  "riffer.guardrail.phase" => phase.to_s
132
- }
136
+ }.merge(tags.transform_keys { |key| "riffer.tag.#{key}" })
133
137
  end
134
138
 
135
139
  #--
@@ -64,14 +64,20 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
64
64
  tools = options[:tools]
65
65
  structured_output = options[:structured_output]
66
66
  cache_control = options[:cache_control]
67
+ tags = options[:tags] || {}
67
68
 
68
69
  params = {
69
70
  model_id: model,
70
71
  system: partitioned_messages[:system],
71
72
  messages: partitioned_messages[:conversation],
72
- **options.except(:tools, :structured_output, :cache_control)
73
+ **options.except(:tools, :structured_output, :cache_control, :tags)
73
74
  } #: Hash[Symbol, untyped]
74
75
 
76
+ # requestMetadata is a flat String=>String map used to filter invocation
77
+ # logs; every tag (including the reserved user_id) rides along, since
78
+ # Converse has no dedicated end-user field.
79
+ params[:request_metadata] = tags unless tags.empty?
80
+
75
81
  if tools && !tools.empty?
76
82
  params[:tool_config] = {
77
83
  tools: tools.map { |t| convert_tool_to_bedrock_format(t) }
@@ -48,6 +48,7 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
48
48
  tools = options[:tools]
49
49
  structured_output = options[:structured_output]
50
50
  web_search = options[:web_search]
51
+ tags = options[:tags] || {}
51
52
 
52
53
  max_tokens = options.fetch(:max_tokens, 4096)
53
54
 
@@ -55,11 +56,17 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
55
56
  model: model,
56
57
  messages: partitioned_messages[:conversation],
57
58
  max_tokens: max_tokens,
58
- **options.except(:tools, :max_tokens, :structured_output, :web_search)
59
+ **options.except(:tools, :max_tokens, :structured_output, :web_search, :tags)
59
60
  } #: Hash[Symbol, untyped]
60
61
 
61
62
  params[:system] = partitioned_messages[:system] if partitioned_messages[:system]
62
63
 
64
+ # Anthropic's only request-metadata field is metadata.user_id (opaque, no
65
+ # PII). It carries the reserved user_id tag; all other tags are dropped
66
+ # here and survive only on spans/metrics.
67
+ user_id = tags["user_id"]
68
+ params[:metadata] = {user_id: user_id} if user_id
69
+
63
70
  anthropic_tools = [] #: Array[Hash[Symbol, untyped]]
64
71
  anthropic_tools.concat(tools.map { |t| convert_tool_to_anthropic_format(t) }) if tools && !tools.empty?
65
72
 
@@ -46,6 +46,7 @@ class Riffer::Providers::Base
46
46
  validate_normalized_messages!(messages)
47
47
  messages = merge_consecutive_messages(messages)
48
48
  params = build_request_params(messages, model, options)
49
+ tags = options[:tags] || {}
49
50
 
50
51
  in_chat_span(model, messages, options) do |span|
51
52
  response = execute_generate(params)
@@ -57,8 +58,8 @@ class Riffer::Providers::Base
57
58
  structured_output = parse_structured_output(content) if options[:structured_output] && tool_calls.empty?
58
59
 
59
60
  Riffer::Tracing.record_usage(span, token_usage)
60
- record_token_usage_metric(model, token_usage)
61
- record_cost_metric(model, token_usage)
61
+ record_token_usage_metric(model, token_usage, tags)
62
+ record_cost_metric(model, token_usage, tags)
62
63
  record_finish_reason(span, finish_reason&.reason, finish_reason&.raw)
63
64
  capture_output(span, content: content, tool_calls: tool_calls, finish_reason: finish_reason&.reason)
64
65
 
@@ -84,10 +85,12 @@ class Riffer::Providers::Base
84
85
  validate_normalized_messages!(messages)
85
86
  messages = merge_consecutive_messages(messages)
86
87
  params = build_request_params(messages, model, options)
88
+ tags = options[:tags] || {}
87
89
 
88
90
  # The enumerator body runs in its own fiber, where the fiber-local OTEL
89
- # context is empty — capture here so the chat span parents to the
90
- # caller's trace.
91
+ # context is empty — capture here so the chat span parents to the caller's
92
+ # trace. tags are an ordinary local captured in the closure, so they reach
93
+ # the span/metric builders without re-propagation.
91
94
  trace_context = Riffer::Tracing.current_context
92
95
  Enumerator.new do |yielder|
93
96
  Riffer::Tracing.with_context(trace_context) do
@@ -99,8 +102,8 @@ class Riffer::Providers::Base
99
102
  execute_stream(params, sink)
100
103
  if sink.is_a?(Riffer::Tracing::StreamRecorder)
101
104
  record_stream_outcome(span, sink)
102
- record_token_usage_metric(model, sink.token_usage)
103
- record_cost_metric(model, sink.token_usage)
105
+ record_token_usage_metric(model, sink.token_usage, tags)
106
+ record_cost_metric(model, sink.token_usage, tags)
104
107
  end
105
108
  end
106
109
  end
@@ -224,6 +227,7 @@ class Riffer::Providers::Base
224
227
  def in_chat_span(model, messages, options)
225
228
  start = Riffer::Metrics.monotonic_now
226
229
  error_type = nil #: String?
230
+ tags = options[:tags] || {} #: Hash[String, String]
227
231
  begin
228
232
  Riffer::Tracing.in_span(model ? "chat #{model}" : "chat", attributes: chat_span_attributes(model, options), kind: :client) do |span|
229
233
  capture_input(span, messages)
@@ -240,7 +244,7 @@ class Riffer::Providers::Base
240
244
  error_type = error.class.name #: String?
241
245
  raise
242
246
  ensure
243
- Riffer::Metrics::Instruments::OPERATION_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: chat_metric_attributes(model, error_type))
247
+ Riffer::Metrics::Instruments::OPERATION_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: chat_metric_attributes(model, error_type, tags))
244
248
  end
245
249
  end
246
250
 
@@ -258,47 +262,55 @@ class Riffer::Providers::Base
258
262
  attributes[attribute] = value unless value.nil?
259
263
  end
260
264
 
261
- attributes
265
+ attributes.merge(tag_attributes(options[:tags] || {}))
262
266
  end
263
267
 
264
268
  #--
265
- #: (String?) -> Hash[String, untyped]
266
- def chat_metric_base_attributes(model)
269
+ #: (String?, ?Hash[String, String]) -> Hash[String, untyped]
270
+ def chat_metric_base_attributes(model, tags = {})
267
271
  attributes = {
268
272
  "gen_ai.operation.name" => "chat",
269
273
  "gen_ai.provider.name" => self.class.semconv_provider_name
270
274
  } #: Hash[String, untyped]
271
275
  attributes["gen_ai.request.model"] = model if model
272
- attributes
276
+ attributes.merge(tag_attributes(tags))
273
277
  end
274
278
 
275
279
  #--
276
- #: (String?, String?) -> Hash[String, untyped]
277
- def chat_metric_attributes(model, error_type)
278
- attributes = chat_metric_base_attributes(model)
280
+ #: (String?, String?, ?Hash[String, String]) -> Hash[String, untyped]
281
+ def chat_metric_attributes(model, error_type, tags = {})
282
+ attributes = chat_metric_base_attributes(model, tags)
279
283
  attributes["error.type"] = error_type if error_type
280
284
  attributes
281
285
  end
282
286
 
287
+ # Maps normalized tags to their namespaced span/metric attribute form. An
288
+ # empty map yields an empty hash, so merging it is a no-op.
289
+ #--
290
+ #: (Hash[String, String]) -> Hash[String, String]
291
+ def tag_attributes(tags)
292
+ tags.transform_keys { |key| "riffer.tag.#{key}" }
293
+ end
294
+
283
295
  # Per-call only — the run level would double-count an aggregate.
284
296
  #--
285
- #: (String?, Riffer::Providers::TokenUsage?) -> void
286
- def record_token_usage_metric(model, usage)
297
+ #: (String?, Riffer::Providers::TokenUsage?, ?Hash[String, String]) -> void
298
+ def record_token_usage_metric(model, usage, tags = {})
287
299
  return unless usage
288
300
 
289
- base = chat_metric_base_attributes(model)
301
+ base = chat_metric_base_attributes(model, tags)
290
302
  Riffer::Metrics::Instruments::TOKEN_USAGE.record(usage.input_tokens, attributes: base.merge("gen_ai.token.type" => "input"))
291
303
  Riffer::Metrics::Instruments::TOKEN_USAGE.record(usage.output_tokens, attributes: base.merge("gen_ai.token.type" => "output"))
292
304
  end
293
305
 
294
306
  # Per-call only — the run level would double-count an aggregate.
295
307
  #--
296
- #: (String?, Riffer::Providers::TokenUsage?) -> void
297
- def record_cost_metric(model, usage)
308
+ #: (String?, Riffer::Providers::TokenUsage?, ?Hash[String, String]) -> void
309
+ def record_cost_metric(model, usage, tags = {})
298
310
  cost = usage&.cost
299
311
  return unless cost
300
312
 
301
- Riffer::Metrics::Instruments::COST.record(cost, attributes: chat_metric_base_attributes(model))
313
+ Riffer::Metrics::Instruments::COST.record(cost, attributes: chat_metric_base_attributes(model, tags))
302
314
  end
303
315
 
304
316
  #--
@@ -67,7 +67,11 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
67
67
  }]
68
68
  end
69
69
 
70
- generation_config = options.except(:tools, :structured_output)
70
+ # tags propagate to observability only: the Gemini Developer API has no
71
+ # request labels field (unknown body fields are rejected), so :tags is
72
+ # stripped here rather than mapped. Native labels would arrive with a Vertex
73
+ # adapter. See docs/10_CONFIGURATION.md.
74
+ generation_config = options.except(:tools, :structured_output, :tags)
71
75
 
72
76
  if structured_output
73
77
  generation_config[:responseMimeType] = "application/json"
@@ -30,6 +30,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
30
30
  tools = options[:tools]
31
31
  structured_output = options[:structured_output]
32
32
  web_search = options[:web_search]
33
+ tags = options[:tags] || {}
33
34
 
34
35
  params = {
35
36
  input: convert_messages_to_openai_format(messages),
@@ -38,9 +39,17 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
38
39
  effort: reasoning,
39
40
  summary: "auto"
40
41
  },
41
- **options.except(:reasoning, :tools, :structured_output, :web_search)
42
+ **options.except(:reasoning, :tools, :structured_output, :web_search, :tags)
42
43
  } #: Hash[Symbol, untyped]
43
44
 
45
+ unless tags.empty?
46
+ params[:metadata] = tags
47
+ # The reserved user_id also maps to the native safety identifier while
48
+ # staying in metadata as an ordinary tag.
49
+ user_id = tags["user_id"]
50
+ params[:safety_identifier] = user_id if user_id
51
+ end
52
+
44
53
  openai_tools = [] #: Array[Hash[Symbol, untyped]]
45
54
  openai_tools.concat(tools.map { |t| convert_tool_to_openai_format(t) }) if tools && !tools.empty?
46
55
 
@@ -44,13 +44,22 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
44
44
  reasoning = options[:reasoning]
45
45
  tools = options[:tools]
46
46
  structured_output = options[:structured_output]
47
+ tags = options[:tags] || {}
47
48
 
48
49
  params = {
49
50
  model: model,
50
51
  messages: convert_messages_to_chat_completions_format(messages),
51
- **options.except(:reasoning, :tools, :structured_output)
52
+ **options.except(:reasoning, :tools, :structured_output, :tags)
52
53
  } #: Hash[Symbol, untyped]
53
54
 
55
+ unless tags.empty?
56
+ params[:metadata] = tags
57
+ # OpenRouter exposes the legacy Chat Completions user field rather than
58
+ # safety_identifier; the reserved user_id maps there and stays in metadata.
59
+ user = tags["user_id"]
60
+ params[:user] = user if user
61
+ end
62
+
54
63
  if reasoning
55
64
  params[:reasoning] = reasoning.is_a?(String) ? {effort: reasoning} : reasoning
56
65
  end
@@ -18,14 +18,16 @@ class Riffer::Tools::Runtime
18
18
 
19
19
  # Executes a batch of tool calls, returning <tt>[tool_call, response]</tt> pairs.
20
20
  #--
21
- #: (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Riffer::Agent::Context?, ?assistant_message: Riffer::Messages::Assistant?) -> Array[[Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]]
22
- def execute(tool_calls, tools:, context:, assistant_message: nil)
21
+ #: (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Riffer::Agent::Context?, ?assistant_message: Riffer::Messages::Assistant?, ?tags: Hash[String, String]) -> Array[[Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]]
22
+ def execute(tool_calls, tools:, context:, assistant_message: nil, tags: {})
23
23
  # Each Runner worker runs in its own thread/fiber, where the OTEL context
24
24
  # starts empty — capture here so the execute_tool span parents correctly.
25
+ # tags are an ordinary local captured in the block, so they reach the
26
+ # worker's span/metric without re-propagation.
25
27
  trace_context = Riffer::Tracing.current_context
26
28
  @runner.map(tool_calls, context: context) do |tool_call|
27
29
  Riffer::Tracing.with_context(trace_context) do
28
- instrument_tool_call(tool_call) do
30
+ instrument_tool_call(tool_call, tags) do
29
31
  around_tool_call(tool_call, context: context, assistant_message: assistant_message) do
30
32
  dispatch_tool_call(tool_call, tools: tools, context: context, assistant_message: assistant_message)
31
33
  end
@@ -57,12 +59,12 @@ class Riffer::Tools::Runtime
57
59
  private
58
60
 
59
61
  #--
60
- #: (Riffer::Messages::Assistant::ToolCall) { () -> Riffer::Tools::Response } -> [Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]
61
- def instrument_tool_call(tool_call)
62
+ #: (Riffer::Messages::Assistant::ToolCall, ?Hash[String, String]) { () -> Riffer::Tools::Response } -> [Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]
63
+ def instrument_tool_call(tool_call, tags = {})
62
64
  start = Riffer::Metrics.monotonic_now
63
65
  error_type = nil #: String?
64
66
  begin
65
- result = in_tool_span(tool_call) do |span|
67
+ result = in_tool_span(tool_call, tags) do |span|
66
68
  response = yield
67
69
  record_tool_outcome(span, response)
68
70
  response
@@ -73,7 +75,7 @@ class Riffer::Tools::Runtime
73
75
  error_type = error.class.name #: String?
74
76
  raise
75
77
  ensure
76
- Riffer::Metrics::Instruments::OPERATION_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: tool_metric_attributes(tool_call, error_type))
78
+ Riffer::Metrics::Instruments::OPERATION_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: tool_metric_attributes(tool_call, error_type, tags))
77
79
  end
78
80
  end
79
81
 
@@ -113,9 +115,9 @@ class Riffer::Tools::Runtime
113
115
 
114
116
  # Emitted outside +around_tool_call+ so host enrichment spans nest beneath it.
115
117
  #--
116
- #: [R] (Riffer::Messages::Assistant::ToolCall) { ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span)) -> R } -> R
117
- def in_tool_span(tool_call)
118
- Riffer::Tracing.in_span("execute_tool #{tool_call.name}", attributes: tool_span_attributes(tool_call), kind: :internal) do |span|
118
+ #: [R] (Riffer::Messages::Assistant::ToolCall, ?Hash[String, String]) { ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span)) -> R } -> R
119
+ def in_tool_span(tool_call, tags = {})
120
+ Riffer::Tracing.in_span("execute_tool #{tool_call.name}", attributes: tool_span_attributes(tool_call, tags), kind: :internal) do |span|
119
121
  capture_tool_arguments(span, tool_call)
120
122
  yield span
121
123
  rescue => error
@@ -127,24 +129,32 @@ class Riffer::Tools::Runtime
127
129
  end
128
130
 
129
131
  #--
130
- #: (Riffer::Messages::Assistant::ToolCall) -> Hash[String, untyped]
131
- def tool_span_attributes(tool_call)
132
+ #: (Riffer::Messages::Assistant::ToolCall, ?Hash[String, String]) -> Hash[String, untyped]
133
+ def tool_span_attributes(tool_call, tags = {})
132
134
  {
133
135
  "gen_ai.operation.name" => "execute_tool",
134
136
  "gen_ai.tool.name" => tool_call.name,
135
137
  "gen_ai.tool.call.id" => tool_call.call_id
136
- }
138
+ }.merge(tag_attributes(tags))
137
139
  end
138
140
 
139
141
  #--
140
- #: (Riffer::Messages::Assistant::ToolCall, String?) -> Hash[String, untyped]
141
- def tool_metric_attributes(tool_call, error_type)
142
+ #: (Riffer::Messages::Assistant::ToolCall, String?, ?Hash[String, String]) -> Hash[String, untyped]
143
+ def tool_metric_attributes(tool_call, error_type, tags = {})
142
144
  attributes = {
143
145
  "gen_ai.operation.name" => "execute_tool",
144
146
  "gen_ai.tool.name" => tool_call.name
145
147
  } #: Hash[String, untyped]
146
148
  attributes["error.type"] = error_type if error_type
147
- attributes
149
+ attributes.merge(tag_attributes(tags))
150
+ end
151
+
152
+ # Maps normalized tags to their namespaced span/metric attribute form. An
153
+ # empty map yields an empty hash, so merging it is a no-op.
154
+ #--
155
+ #: (Hash[String, String]) -> Hash[String, String]
156
+ def tag_attributes(tags)
157
+ tags.transform_keys { |key| "riffer.tag.#{key}" }
148
158
  end
149
159
 
150
160
  # A returned error Response is a handled outcome, so its status stays unset —
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.34.0" #: String
5
+ VERSION = "0.35.0" #: String
6
6
  end
@@ -7,29 +7,34 @@ module Riffer::Agent::Run
7
7
  # for prompt/files semantics.
8
8
  #
9
9
  # --
10
- # : (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Riffer::Agent::Response
11
- def generate: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Riffer::Agent::Response
10
+ # : (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Riffer::Agent::Response
11
+ def generate: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[String | Symbol, untyped]) -> Riffer::Agent::Response
12
12
 
13
13
  # Runs the streaming loop for the given agent. See Riffer::Agent#stream
14
14
  # for prompt/files semantics.
15
15
  #
16
16
  # --
17
- # : (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
18
- def stream: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
17
+ # : (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
18
+ def stream: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[String | Symbol, untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
19
19
 
20
20
  private
21
21
 
22
+ # Both +generate+ and +stream+ funnel here, so this is the single place raw
23
+ # +tags+ are normalized. The clean <tt>String => String</tt> map is then
24
+ # threaded to every span/metric builder in the run as +riffer.tag.*+ and to
25
+ # each provider call (via +merged_model_options+) for native request-metadata
26
+ # mapping.
22
27
  # --
23
- # : (Riffer::Agent, ?stream_yielder: Enumerator::Yielder?) -> Riffer::Agent::Response
24
- def run_loop: (Riffer::Agent, ?stream_yielder: Enumerator::Yielder?) -> Riffer::Agent::Response
28
+ # : (Riffer::Agent, ?tags: Hash[(String | Symbol), untyped]?, ?stream_yielder: Enumerator::Yielder?) -> Riffer::Agent::Response
29
+ def run_loop: (Riffer::Agent, ?tags: Hash[String | Symbol, untyped]?, ?stream_yielder: Enumerator::Yielder?) -> Riffer::Agent::Response
25
30
 
26
31
  # --
27
- # : (Riffer::Agent, Enumerator::Yielder?) -> Riffer::Agent::Response
28
- def execute_run: (Riffer::Agent, Enumerator::Yielder?) -> Riffer::Agent::Response
32
+ # : (Riffer::Agent, Enumerator::Yielder?, ?Hash[String, String]) -> Riffer::Agent::Response
33
+ def execute_run: (Riffer::Agent, Enumerator::Yielder?, ?Hash[String, String]) -> Riffer::Agent::Response
29
34
 
30
35
  # --
31
- # : (Riffer::Agent, Enumerator::Yielder) -> Riffer::Messages::Assistant
32
- def accumulate_streamed_response: (Riffer::Agent, Enumerator::Yielder) -> Riffer::Messages::Assistant
36
+ # : (Riffer::Agent, Enumerator::Yielder, ?Hash[String, String]) -> Riffer::Messages::Assistant
37
+ def accumulate_streamed_response: (Riffer::Agent, Enumerator::Yielder, ?Hash[String, String]) -> Riffer::Messages::Assistant
33
38
 
34
39
  # --
35
40
  # : (Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], Array[Riffer::Guardrails::Modification]) -> void
@@ -44,32 +49,32 @@ module Riffer::Agent::Run
44
49
  def final_response: (Riffer::Agent, Array[Riffer::Guardrails::Modification], **untyped) -> Riffer::Agent::Response
45
50
 
46
51
  # --
47
- # : (Riffer::Agent) -> Riffer::Messages::Assistant
48
- def call_llm: (Riffer::Agent) -> Riffer::Messages::Assistant
52
+ # : (Riffer::Agent, ?Hash[String, String]) -> Riffer::Messages::Assistant
53
+ def call_llm: (Riffer::Agent, ?Hash[String, String]) -> Riffer::Messages::Assistant
49
54
 
50
55
  # --
51
- # : (Riffer::Agent) -> Enumerator[Riffer::StreamEvents::Base, void]
52
- def call_llm_stream: (Riffer::Agent) -> Enumerator[Riffer::StreamEvents::Base, void]
56
+ # : (Riffer::Agent, ?Hash[String, String]) -> Enumerator[Riffer::StreamEvents::Base, void]
57
+ def call_llm_stream: (Riffer::Agent, ?Hash[String, String]) -> Enumerator[Riffer::StreamEvents::Base, void]
53
58
 
54
59
  # --
55
- # : (Riffer::Agent, Riffer::Messages::Assistant, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall]) -> void
56
- def execute_tool_calls: (Riffer::Agent, Riffer::Messages::Assistant, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall]) -> void
60
+ # : (Riffer::Agent, Riffer::Messages::Assistant, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?tags: Hash[String, String]) -> void
61
+ def execute_tool_calls: (Riffer::Agent, Riffer::Messages::Assistant, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?tags: Hash[String, String]) -> void
57
62
 
58
63
  # --
59
64
  # : (Riffer::Agent, Array[[Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]]) -> void
60
65
  def inject_discovered_tools: (Riffer::Agent, Array[[ Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response ]]) -> void
61
66
 
62
67
  # --
63
- # : (Riffer::Agent) -> void
64
- def execute_pending_tool_calls: (Riffer::Agent) -> void
68
+ # : (Riffer::Agent, ?Hash[String, String]) -> void
69
+ def execute_pending_tool_calls: (Riffer::Agent, ?Hash[String, String]) -> void
65
70
 
66
71
  # --
67
- # : (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Guardrails::Tripwire) -> void } -> void
68
- def run_before_guardrails: (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Guardrails::Tripwire) -> void } -> void
72
+ # : (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], ?Hash[String, String]) { (Riffer::Guardrails::Tripwire) -> void } -> void
73
+ def run_before_guardrails: (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], ?Hash[String, String]) { (Riffer::Guardrails::Tripwire) -> void } -> void
69
74
 
70
75
  # --
71
- # : (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Guardrails::Tripwire) -> void } -> untyped
72
- def run_after_guardrails: (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Guardrails::Tripwire) -> void } -> untyped
76
+ # : (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], ?Hash[String, String]) { (Riffer::Guardrails::Tripwire) -> void } -> untyped
77
+ def run_after_guardrails: (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], ?Hash[String, String]) { (Riffer::Guardrails::Tripwire) -> void } -> untyped
73
78
 
74
79
  # --
75
80
  # : (Riffer::Agent, Riffer::Messages::Assistant?) -> Hash[Symbol, untyped]?
@@ -79,9 +84,13 @@ module Riffer::Agent::Run
79
84
  # : (Riffer::Agent) -> Array[singleton(Riffer::Tool)]
80
85
  def effective_tools: (Riffer::Agent) -> Array[singleton(Riffer::Tool)]
81
86
 
87
+ # +tags+ rides in the options hash as a curated key the providers extract for
88
+ # native request-metadata mapping (alongside +:structured_output+); it never
89
+ # reaches an SDK call verbatim. Span/metric tagging is threaded separately to
90
+ # each builder.
82
91
  # --
83
- # : (Riffer::Agent) -> Hash[Symbol, untyped]
84
- def merged_model_options: (Riffer::Agent) -> Hash[Symbol, untyped]
92
+ # : (Riffer::Agent, ?Hash[String, String]) -> Hash[Symbol, untyped]
93
+ def merged_model_options: (Riffer::Agent, ?Hash[String, String]) -> Hash[Symbol, untyped]
85
94
 
86
95
  # --
87
96
  # : (Riffer::Agent, String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?healed_tool_call_ids: Array[String], ?token_usage: Riffer::Providers::TokenUsage?, ?steps: Integer) -> Riffer::Agent::Response
@@ -102,12 +111,20 @@ module Riffer::Agent::Run
102
111
  def sum_usage: (Riffer::Providers::TokenUsage?, Riffer::Providers::TokenUsage?) -> Riffer::Providers::TokenUsage?
103
112
 
104
113
  # --
105
- # : (Riffer::Agent) -> Hash[String, untyped]
106
- def run_span_attributes: (Riffer::Agent) -> Hash[String, untyped]
114
+ # : (Riffer::Agent, ?Hash[String, String]) -> Hash[String, untyped]
115
+ def run_span_attributes: (Riffer::Agent, ?Hash[String, String]) -> Hash[String, untyped]
107
116
 
108
117
  # --
109
- # : (Riffer::Agent, String?) -> Hash[String, untyped]
110
- def run_metric_attributes: (Riffer::Agent, String?) -> Hash[String, untyped]
118
+ # : (Riffer::Agent, String?, ?Hash[String, String]) -> Hash[String, untyped]
119
+ def run_metric_attributes: (Riffer::Agent, String?, ?Hash[String, String]) -> Hash[String, untyped]
120
+
121
+ # --
122
+ # : (Hash[(String | Symbol), untyped]?) -> Hash[String, String]
123
+ def normalize_tags: (Hash[String | Symbol, untyped]?) -> Hash[String, String]
124
+
125
+ # --
126
+ # : (Hash[String, String]) -> Hash[String, String]
127
+ def tag_attributes: (Hash[String, String]) -> Hash[String, String]
111
128
 
112
129
  # --
113
130
  # : (Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span, Riffer::Agent::Response) -> void
@@ -124,13 +124,13 @@ class Riffer::Agent
124
124
 
125
125
  # Generates a response using a new agent instance.
126
126
  # --
127
- # : (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
128
- def self.generate: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
127
+ # : (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[(String | Symbol), untyped]) -> Riffer::Agent::Response
128
+ def self.generate: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[String | Symbol, untyped]) -> Riffer::Agent::Response
129
129
 
130
130
  # Streams a response using a new agent instance.
131
131
  # --
132
- # : (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Enumerator[Riffer::StreamEvents::Base, void]
133
- def self.stream: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Enumerator[Riffer::StreamEvents::Base, void]
132
+ # : (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
133
+ def self.stream: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[String | Symbol, untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
134
134
 
135
135
  # Reconstructs a runnable agent from a wire hash produced by +#to_h+.
136
136
  # --
@@ -213,18 +213,25 @@ class Riffer::Agent
213
213
  # against the current session, resuming a persisted conversation or pending
214
214
  # tool calls. +files:+ requires +prompt+.
215
215
  #
216
+ # +tags:+ is an optional flat hash of attribution tags applied to this single
217
+ # call: they propagate to the provider's native request-metadata field, and
218
+ # are stamped as +riffer.tag.*+ on every span and metric the call emits. See
219
+ # +docs/03_AGENTS.md+ for the per-provider mapping. The reserved key
220
+ # +user_id+ also maps to the provider's native user identifier where one
221
+ # exists.
222
+ #
216
223
  # --
217
- # : (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Riffer::Agent::Response
218
- def generate: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Riffer::Agent::Response
224
+ # : (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Riffer::Agent::Response
225
+ def generate: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[String | Symbol, untyped]) -> Riffer::Agent::Response
219
226
 
220
227
  # Streams a response from the agent, returning an +Enumerator+ of
221
- # +Riffer::StreamEvents+. See +#generate+ for prompt/files semantics.
228
+ # +Riffer::StreamEvents+. See +#generate+ for prompt/files/tags semantics.
222
229
  #
223
230
  # Raises Riffer::ArgumentError if structured output is configured.
224
231
  #
225
232
  # --
226
- # : (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
227
- def stream: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
233
+ # : (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
234
+ def stream: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[String | Symbol, untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
228
235
 
229
236
  # Interrupts the agent loop from an +on_message+ callback. Equivalent to
230
237
  # <tt>throw :riffer_interrupt, reason</tt>.
@@ -12,9 +12,12 @@ class Riffer::Guardrails::Runner
12
12
  # The context passed to guardrails.
13
13
  attr_reader context: untyped
14
14
 
15
+ # The normalized per-call tags, stamped as +riffer.tag.*+ on guardrail spans.
16
+ attr_reader tags: Hash[String, String]
17
+
15
18
  # --
16
- # : (Array[Hash[Symbol, untyped]], phase: Symbol, ?context: untyped) -> void
17
- def initialize: (Array[Hash[Symbol, untyped]], phase: Symbol, ?context: untyped) -> void
19
+ # : (Array[Hash[Symbol, untyped]], phase: Symbol, ?context: untyped, ?tags: Hash[String, String]) -> void
20
+ def initialize: (Array[Hash[Symbol, untyped]], phase: Symbol, ?context: untyped, ?tags: Hash[String, String]) -> void
18
21
 
19
22
  # Runs the guardrails sequentially. For the +:before+ phase +data+ is the
20
23
  # messages array; for +:after+ it's the response (and +messages+ must be
@@ -99,22 +99,28 @@ class Riffer::Providers::Base
99
99
  def chat_span_attributes: (String?, Hash[Symbol, untyped]) -> Hash[String, untyped]
100
100
 
101
101
  # --
102
- # : (String?) -> Hash[String, untyped]
103
- def chat_metric_base_attributes: (String?) -> Hash[String, untyped]
102
+ # : (String?, ?Hash[String, String]) -> Hash[String, untyped]
103
+ def chat_metric_base_attributes: (String?, ?Hash[String, String]) -> Hash[String, untyped]
104
104
 
105
105
  # --
106
- # : (String?, String?) -> Hash[String, untyped]
107
- def chat_metric_attributes: (String?, String?) -> Hash[String, untyped]
106
+ # : (String?, String?, ?Hash[String, String]) -> Hash[String, untyped]
107
+ def chat_metric_attributes: (String?, String?, ?Hash[String, String]) -> Hash[String, untyped]
108
+
109
+ # Maps normalized tags to their namespaced span/metric attribute form. An
110
+ # empty map yields an empty hash, so merging it is a no-op.
111
+ # --
112
+ # : (Hash[String, String]) -> Hash[String, String]
113
+ def tag_attributes: (Hash[String, String]) -> Hash[String, String]
108
114
 
109
115
  # Per-call only — the run level would double-count an aggregate.
110
116
  # --
111
- # : (String?, Riffer::Providers::TokenUsage?) -> void
112
- def record_token_usage_metric: (String?, Riffer::Providers::TokenUsage?) -> void
117
+ # : (String?, Riffer::Providers::TokenUsage?, ?Hash[String, String]) -> void
118
+ def record_token_usage_metric: (String?, Riffer::Providers::TokenUsage?, ?Hash[String, String]) -> void
113
119
 
114
120
  # Per-call only — the run level would double-count an aggregate.
115
121
  # --
116
- # : (String?, Riffer::Providers::TokenUsage?) -> void
117
- def record_cost_metric: (String?, Riffer::Providers::TokenUsage?) -> void
122
+ # : (String?, Riffer::Providers::TokenUsage?, ?Hash[String, String]) -> void
123
+ def record_cost_metric: (String?, Riffer::Providers::TokenUsage?, ?Hash[String, String]) -> void
118
124
 
119
125
  # --
120
126
  # : ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Symbol?, String?) -> void
@@ -12,8 +12,8 @@ class Riffer::Tools::Runtime
12
12
 
13
13
  # Executes a batch of tool calls, returning <tt>[tool_call, response]</tt> pairs.
14
14
  # --
15
- # : (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Riffer::Agent::Context?, ?assistant_message: Riffer::Messages::Assistant?) -> Array[[Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]]
16
- def execute: (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Riffer::Agent::Context?, ?assistant_message: Riffer::Messages::Assistant?) -> Array[[ Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response ]]
15
+ # : (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Riffer::Agent::Context?, ?assistant_message: Riffer::Messages::Assistant?, ?tags: Hash[String, String]) -> Array[[Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]]
16
+ def execute: (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Riffer::Agent::Context?, ?assistant_message: Riffer::Messages::Assistant?, ?tags: Hash[String, String]) -> Array[[ Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response ]]
17
17
 
18
18
  # Hook wrapping each tool call; override in subclasses to instrument or
19
19
  # customize. Must +yield+ to continue.
@@ -36,8 +36,8 @@ class Riffer::Tools::Runtime
36
36
  private
37
37
 
38
38
  # --
39
- # : (Riffer::Messages::Assistant::ToolCall) { () -> Riffer::Tools::Response } -> [Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]
40
- def instrument_tool_call: (Riffer::Messages::Assistant::ToolCall) { () -> Riffer::Tools::Response } -> [ Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response ]
39
+ # : (Riffer::Messages::Assistant::ToolCall, ?Hash[String, String]) { () -> Riffer::Tools::Response } -> [Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]
40
+ def instrument_tool_call: (Riffer::Messages::Assistant::ToolCall, ?Hash[String, String]) { () -> Riffer::Tools::Response } -> [ Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response ]
41
41
 
42
42
  # --
43
43
  # : (Riffer::Messages::Assistant::ToolCall, tools: Array[singleton(Riffer::Tool)], context: Riffer::Agent::Context?, ?assistant_message: Riffer::Messages::Assistant?) -> Riffer::Tools::Response
@@ -49,16 +49,22 @@ class Riffer::Tools::Runtime
49
49
 
50
50
  # Emitted outside +around_tool_call+ so host enrichment spans nest beneath it.
51
51
  # --
52
- # : [R] (Riffer::Messages::Assistant::ToolCall) { ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span)) -> R } -> R
53
- def in_tool_span: [R] (Riffer::Messages::Assistant::ToolCall) { (Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span) -> R } -> R
52
+ # : [R] (Riffer::Messages::Assistant::ToolCall, ?Hash[String, String]) { ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span)) -> R } -> R
53
+ def in_tool_span: [R] (Riffer::Messages::Assistant::ToolCall, ?Hash[String, String]) { (Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span) -> R } -> R
54
54
 
55
55
  # --
56
- # : (Riffer::Messages::Assistant::ToolCall) -> Hash[String, untyped]
57
- def tool_span_attributes: (Riffer::Messages::Assistant::ToolCall) -> Hash[String, untyped]
56
+ # : (Riffer::Messages::Assistant::ToolCall, ?Hash[String, String]) -> Hash[String, untyped]
57
+ def tool_span_attributes: (Riffer::Messages::Assistant::ToolCall, ?Hash[String, String]) -> Hash[String, untyped]
58
58
 
59
59
  # --
60
- # : (Riffer::Messages::Assistant::ToolCall, String?) -> Hash[String, untyped]
61
- def tool_metric_attributes: (Riffer::Messages::Assistant::ToolCall, String?) -> Hash[String, untyped]
60
+ # : (Riffer::Messages::Assistant::ToolCall, String?, ?Hash[String, String]) -> Hash[String, untyped]
61
+ def tool_metric_attributes: (Riffer::Messages::Assistant::ToolCall, String?, ?Hash[String, String]) -> Hash[String, untyped]
62
+
63
+ # Maps normalized tags to their namespaced span/metric attribute form. An
64
+ # empty map yields an empty hash, so merging it is a no-op.
65
+ # --
66
+ # : (Hash[String, String]) -> Hash[String, String]
67
+ def tag_attributes: (Hash[String, String]) -> Hash[String, String]
62
68
 
63
69
  # A returned error Response is a handled outcome, so its status stays unset —
64
70
  # an error span status is reserved for a raised exception.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: riffer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.34.0
4
+ version: 0.35.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall