riffer 0.34.0 → 0.36.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +18 -0
  4. data/README.md +0 -1
  5. data/docs/01_OVERVIEW.md +0 -1
  6. data/docs/03_AGENTS.md +40 -0
  7. data/docs/10_CONFIGURATION.md +0 -17
  8. data/docs/16_TRACING.md +4 -0
  9. data/lib/riffer/agent/run.rb +79 -70
  10. data/lib/riffer/agent.rb +20 -13
  11. data/lib/riffer/config.rb +0 -45
  12. data/lib/riffer/guardrails/runner.rb +14 -35
  13. data/lib/riffer/providers/amazon_bedrock.rb +7 -1
  14. data/lib/riffer/providers/anthropic.rb +8 -1
  15. data/lib/riffer/providers/base.rb +16 -68
  16. data/lib/riffer/providers/gemini.rb +5 -1
  17. data/lib/riffer/providers/open_ai.rb +10 -1
  18. data/lib/riffer/providers/open_router.rb +10 -1
  19. data/lib/riffer/tools/runtime.rb +23 -34
  20. data/lib/riffer/version.rb +1 -1
  21. data/sig/_private/opentelemetry.rbs +3 -6
  22. data/sig/generated/riffer/agent/run.rbs +41 -28
  23. data/sig/generated/riffer/agent.rbs +16 -9
  24. data/sig/generated/riffer/config.rbs +0 -33
  25. data/sig/generated/riffer/guardrails/runner.rbs +5 -10
  26. data/sig/generated/riffer/providers/base.rbs +4 -16
  27. data/sig/generated/riffer/tools/runtime.rbs +12 -10
  28. metadata +5 -16
  29. data/docs/17_METRICS.md +0 -153
  30. data/lib/riffer/metrics/instruments.rb +0 -31
  31. data/lib/riffer/metrics/no_op.rb +0 -14
  32. data/lib/riffer/metrics/otel.rb +0 -80
  33. data/lib/riffer/metrics.rb +0 -94
  34. data/sig/generated/riffer/metrics/instruments.rbs +0 -15
  35. data/sig/generated/riffer/metrics/no_op.rbs +0 -10
  36. data/sig/generated/riffer/metrics/otel.rbs +0 -48
  37. data/sig/generated/riffer/metrics.rbs +0 -72
  38. data/sig/manual/riffer/metrics/no_op.rbs +0 -5
  39. data/sig/manual/riffer/metrics.rbs +0 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc442fb9eb0296a5a9c6f289c1c9e63e7cdb732ef52b013e1556d1765a874f2d
4
- data.tar.gz: aa7512f0b3ac38e4f0c7be500095c73fca0ae1740c37370ceb2aeaf57349dac8
3
+ metadata.gz: 491260200757bf1d17b920f099012d771328fd333d46bb325f53dca863bcf204
4
+ data.tar.gz: 072ffcdf47b2aa115a3c7743cb77d4f238d18b926f0a53eb6b45a28148b8b2be
5
5
  SHA512:
6
- metadata.gz: d9ace157326fa9ebfb356705f14cd4a8bac4af86e3ede2eb142adb4be36870399c81ee122fa02973135c9176b521bdee980745e3cc3f14422e12fbf1392fb009
7
- data.tar.gz: cdc2e19b1aab77a93fb6ad7c4a800c5002a5efb63667366113fc04104a05c342863a1fa74219ed2569759f5f7be08dc534ac7c79b10c6d9ab61d6430db805113
6
+ metadata.gz: 97bf5aeed05fe9b591ae5a2bcf7c59d6990a46de1695fa4ff929823ead08b23372f35c4efb389a7e2f769a2a15dbad4dcb35bb1421e57c1fe5291216390b176c
7
+ data.tar.gz: 421d381a7d8c0a1be0c017a2c347096fa452d48d512706c474e82b5d90097e245d9f7680ed6745c10aac31502cdfe47efc56ae885dbd9e4b52b3b7e2803b7c10
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.34.0"
2
+ ".": "0.36.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ 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.36.0](https://github.com/janeapp/riffer/compare/riffer/v0.35.0...riffer/v0.36.0) (2026-07-03)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * config.metrics, the Riffer::Metrics module, and Riffer::Metrics::Otel are removed. Hosts wiring a metrics backend must drop that configuration.
14
+
15
+ ### Features
16
+
17
+ * remove the metrics primitive ([#347](https://github.com/janeapp/riffer/issues/347)) ([70a838e](https://github.com/janeapp/riffer/commit/70a838e9d49da12f89886c6e6f7bca35f427f77c))
18
+
19
+ ## [0.35.0](https://github.com/janeapp/riffer/compare/riffer/v0.34.0...riffer/v0.35.0) (2026-06-25)
20
+
21
+
22
+ ### Features
23
+
24
+ * per-call tags: for Agent#generate and #stream ([#339](https://github.com/janeapp/riffer/issues/339)) ([ddfda89](https://github.com/janeapp/riffer/commit/ddfda8929ca3b594415f827957d8c91104387317))
25
+
8
26
  ## [0.34.0](https://github.com/janeapp/riffer/compare/riffer/v0.33.0...riffer/v0.34.0) (2026-06-22)
9
27
 
10
28
 
data/README.md CHANGED
@@ -63,7 +63,6 @@ For comprehensive documentation, see the [docs](docs/) directory:
63
63
  - [MCP](docs/14_MCP.md) - Integrating third-party MCP servers
64
64
  - [Serialization](docs/15_SERIALIZATION.md) - Persisting and transferring agent definitions
65
65
  - [Tracing](docs/16_TRACING.md) - OpenTelemetry span contract and host wiring
66
- - [Metrics](docs/17_METRICS.md) - OpenTelemetry metric contract and host wiring
67
66
  - [Providers](docs/providers/01_PROVIDERS.md) - LLM provider adapters
68
67
 
69
68
  ### API Reference
data/docs/01_OVERVIEW.md CHANGED
@@ -132,7 +132,6 @@ Response
132
132
  - [Tools](06_TOOLS.md) - Creating tools
133
133
  - [Configuration](10_CONFIGURATION.md) - Global configuration
134
134
  - [Tracing](16_TRACING.md) - OpenTelemetry span contract and host wiring
135
- - [Metrics](17_METRICS.md) - OpenTelemetry metric contract and host wiring
136
135
  - [Evals](11_EVALS.md) - Evaluating agent quality
137
136
  - [Guardrails](12_GUARDRAILS.md) - Input/output validation
138
137
  - [Skills](13_SKILLS.md) - Packaged agent capabilities
data/docs/03_AGENTS.md CHANGED
@@ -317,6 +317,46 @@ 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 the call emits (`invoke_agent`, `chat`, `execute_tool`, `execute_guardrail`). See [Tracing](16_TRACING.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). 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 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
+
320
360
  ## Expand Your Agent
321
361
 
322
362
  | Goal | Feature | Guide |
@@ -119,23 +119,6 @@ end
119
119
  | `capture_messages` | Opt-in capture of full message content on LLM-call spans (`gen_ai.input.messages`, `gen_ai.output.messages`, `gen_ai.system_instructions`) as GenAI-semconv JSON. Defaults to `false` — message content routinely carries sensitive data. File attachments serialize as metadata-only stubs (media type and name, never bytes), and riffer applies no size limit of its own — cap oversized attributes with the OTEL SDK attribute length limits. |
120
120
  | `backend` | The backend riffer routes spans through. Assign `Riffer::Tracing::Otel.build` (pass `provider:` to override the global tracer provider — e.g. an in-memory provider in tests), or any object satisfying the duck-typed contract (`in_span` / `current_context` / `with_context`) to route into a non-OTEL system (e.g. Datadog APM). Defaults to `nil` — a no-op. Raises `Riffer::ArgumentError` unless the value is `nil` or responds to `in_span`. See [Tracing → Routing to a non-OpenTelemetry backend](16_TRACING.md#routing-to-a-non-opentelemetry-backend). |
121
121
 
122
- ### Metrics
123
-
124
- Metrics-related global configuration lives under `config.metrics`, **independent** of `config.tracing` — each has its own kill switch and its own backend, so you can run one signal without the other. Riffer records measurements only through the backend you assign to `config.metrics.backend` — there is **no auto-detection**. OpenTelemetry is a built-in backend you opt into explicitly with `Riffer::Metrics::Otel.build`; with no backend set every measurement is a silent no-op, and riffer carries no OTEL gem dependency either way. The metrics API and SDK are separate, still-experimental (pre-1.0) gems from the traces API.
125
-
126
- ```ruby
127
- Riffer.configure do |config|
128
- config.metrics.enabled = ENV.fetch("RIFFER_METRICS_ENABLED", "true")
129
- # Opt into OpenTelemetry (uses the global meter provider):
130
- config.metrics.backend = Riffer::Metrics::Otel.build
131
- end
132
- ```
133
-
134
- | Option | Description |
135
- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
136
- | `enabled` | The kill switch, consulted on every measurement — flipping it at runtime takes effect immediately, short-circuiting to a no-op ahead of the backend. Accepts booleans or `'true'`/`'false'`/`'1'`/`'0'`. Defaults to `true`. |
137
- | `backend` | The backend riffer routes measurements through. Assign `Riffer::Metrics::Otel.build` (pass `provider:` to override the global meter provider — e.g. an in-memory provider in tests), or any object responding to `record_histogram` to route into a non-OTEL system (e.g. DogStatsD). Defaults to `nil` — a no-op. Raises `Riffer::ArgumentError` unless the value is `nil` or responds to `record_histogram`. See [Metrics → Routing to a non-OpenTelemetry backend](17_METRICS.md#routing-to-a-non-opentelemetry-backend). |
138
-
139
122
  ### Pricing
140
123
 
141
124
  Configure per-model token prices and riffer computes the cost of each LLM call onto its [`TokenUsage`](08_MESSAGES.md#token-usage-semantics). Riffer ships **no** price table — so an unconfigured model simply carries no cost (`token_usage.cost` is `nil`).
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`).
@@ -10,63 +10,60 @@ 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 builder in the run as +riffer.tag.*+ and to each
41
+ # 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)
39
- start = Riffer::Metrics.monotonic_now
40
- error_type = nil #: String?
41
- 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)
44
- record_run_outcome(span, response)
45
- response
46
- rescue => error
47
- # The backend records the exception and error status on the re-raise;
48
- # error.type is the one semconv attribute it doesn't set.
49
- span.set_attribute("error.type", error.class.name)
50
- raise
51
- end
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)
47
+ Riffer::Tracing.in_span("invoke_agent #{agent.class.identifier}", attributes: run_span_attributes(agent, tags), kind: :internal) do |span|
48
+ response = execute_run(agent, stream_yielder, tags)
49
+ record_run_outcome(span, response)
50
+ response
52
51
  rescue => error
53
- # The inner rescue tags the span; capture error.type here too, at method
54
- # scope, where the ensure can read it onto the metric.
55
- error_type = error.class.name #: String?
52
+ # The backend records the exception and error status on the re-raise;
53
+ # error.type is the one semconv attribute it doesn't set.
54
+ span.set_attribute("error.type", error.class.name)
56
55
  raise
57
- ensure
58
- Riffer::Metrics::Instruments::OPERATION_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: run_metric_attributes(agent, error_type))
59
56
  end
60
57
  end
61
58
 
62
59
  #--
63
- #: (Riffer::Agent, Enumerator::Yielder?) -> Riffer::Agent::Response
64
- def execute_run(agent, stream_yielder)
60
+ #: (Riffer::Agent, Enumerator::Yielder?, ?Hash[String, String]) -> Riffer::Agent::Response
61
+ def execute_run(agent, stream_yielder, tags = {})
65
62
  all_modifications = [] #: Array[Riffer::Guardrails::Modification]
66
63
  run_usage = nil #: Riffer::Providers::TokenUsage?
67
64
  run_steps = 0
68
65
 
69
- run_before_guardrails(agent, stream_yielder, all_modifications) do |tripwire|
66
+ run_before_guardrails(agent, stream_yielder, all_modifications, tags) do |tripwire|
70
67
  return tripwire_response(agent, stream_yielder, tripwire, all_modifications, steps: run_steps)
71
68
  end
72
69
 
@@ -84,16 +81,16 @@ module Riffer::Agent::Run
84
81
  step = agent.session.steps
85
82
 
86
83
  reason = catch(:riffer_interrupt) do
87
- execute_pending_tool_calls(agent)
84
+ execute_pending_tool_calls(agent, tags)
88
85
 
89
86
  loop do
90
- response = stream_yielder ? accumulate_streamed_response(agent, stream_yielder) : call_llm(agent)
87
+ response = stream_yielder ? accumulate_streamed_response(agent, stream_yielder, tags) : call_llm(agent, tags)
91
88
  step += 1
92
89
  run_steps += 1
93
90
  track_token_usage(agent, response.token_usage)
94
91
  run_usage = sum_usage(run_usage, response.token_usage)
95
92
 
96
- processed_response = run_after_guardrails(agent, response, stream_yielder, all_modifications) do |tripwire|
93
+ processed_response = run_after_guardrails(agent, response, stream_yielder, all_modifications, tags) do |tripwire|
97
94
  return tripwire_response(agent, stream_yielder, tripwire, all_modifications, token_usage: run_usage, steps: run_steps)
98
95
  end
99
96
 
@@ -104,7 +101,7 @@ module Riffer::Agent::Run
104
101
  max_steps = agent.config.max_steps
105
102
  throw :riffer_interrupt, Riffer::Agent::INTERRUPT_MAX_STEPS if max_steps && step >= max_steps
106
103
 
107
- execute_tool_calls(agent, processed_response)
104
+ execute_tool_calls(agent, processed_response, tags: tags)
108
105
  end
109
106
 
110
107
  return final_response(agent, all_modifications, token_usage: run_usage, steps: run_steps)
@@ -122,14 +119,14 @@ module Riffer::Agent::Run
122
119
  end
123
120
 
124
121
  #--
125
- #: (Riffer::Agent, Enumerator::Yielder) -> Riffer::Messages::Assistant
126
- def accumulate_streamed_response(agent, stream_yielder)
122
+ #: (Riffer::Agent, Enumerator::Yielder, ?Hash[String, String]) -> Riffer::Messages::Assistant
123
+ def accumulate_streamed_response(agent, stream_yielder, tags = {})
127
124
  accumulated_content = ""
128
125
  accumulated_tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
129
126
  accumulated_token_usage = nil #: Riffer::Providers::TokenUsage?
130
127
  accumulated_finish_reason = nil #: Symbol?
131
128
 
132
- call_llm_stream(agent).each do |event|
129
+ call_llm_stream(agent, tags).each do |event|
133
130
  stream_yielder << event
134
131
 
135
132
  case event
@@ -180,33 +177,33 @@ module Riffer::Agent::Run
180
177
  end
181
178
 
182
179
  #--
183
- #: (Riffer::Agent) -> Riffer::Messages::Assistant
184
- def call_llm(agent)
180
+ #: (Riffer::Agent, ?Hash[String, String]) -> Riffer::Messages::Assistant
181
+ def call_llm(agent, tags = {})
185
182
  agent.provider.generate_text(
186
183
  messages: agent.session.messages,
187
184
  model: agent.model_name,
188
185
  tools: effective_tools(agent),
189
- **merged_model_options(agent)
186
+ **merged_model_options(agent, tags)
190
187
  )
191
188
  end
192
189
 
193
190
  #--
194
- #: (Riffer::Agent) -> Enumerator[Riffer::StreamEvents::Base, void]
195
- def call_llm_stream(agent)
191
+ #: (Riffer::Agent, ?Hash[String, String]) -> Enumerator[Riffer::StreamEvents::Base, void]
192
+ def call_llm_stream(agent, tags = {})
196
193
  agent.provider.stream_text(
197
194
  messages: agent.session.messages,
198
195
  model: agent.model_name,
199
196
  tools: effective_tools(agent),
200
- **merged_model_options(agent)
197
+ **merged_model_options(agent, tags)
201
198
  )
202
199
  end
203
200
 
204
201
  #--
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)
202
+ #: (Riffer::Agent, Riffer::Messages::Assistant, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?tags: Hash[String, String]) -> void
203
+ def execute_tool_calls(agent, assistant_message, tool_calls: assistant_message.tool_calls, tags: {})
207
204
  return if tool_calls.empty?
208
205
 
209
- results = agent.tool_runtime.execute(tool_calls, tools: effective_tools(agent), context: agent.context, assistant_message: assistant_message)
206
+ results = agent.tool_runtime.execute(tool_calls, tools: effective_tools(agent), context: agent.context, assistant_message: assistant_message, tags: tags)
210
207
 
211
208
  inject_discovered_tools(agent, results)
212
209
 
@@ -233,19 +230,19 @@ module Riffer::Agent::Run
233
230
  end
234
231
 
235
232
  #--
236
- #: (Riffer::Agent) -> void
237
- def execute_pending_tool_calls(agent)
233
+ #: (Riffer::Agent, ?Hash[String, String]) -> void
234
+ def execute_pending_tool_calls(agent, tags = {})
238
235
  assistant_message, pending = agent.session.pending_tool_calls
239
- execute_tool_calls(agent, assistant_message, tool_calls: pending) if assistant_message
236
+ execute_tool_calls(agent, assistant_message, tool_calls: pending, tags: tags) if assistant_message
240
237
  end
241
238
 
242
239
  #--
243
- #: (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Guardrails::Tripwire) -> void } -> void
244
- def run_before_guardrails(agent, stream_yielder, all_modifications)
240
+ #: (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], ?Hash[String, String]) { (Riffer::Guardrails::Tripwire) -> void } -> void
241
+ def run_before_guardrails(agent, stream_yielder, all_modifications, tags = {})
245
242
  guardrails = agent.config.guardrails_for(:before)
246
243
  return if guardrails.empty?
247
244
 
248
- runner = Riffer::Guardrails::Runner.new(guardrails, phase: :before, context: agent.context)
245
+ runner = Riffer::Guardrails::Runner.new(guardrails, phase: :before, context: agent.context, tags: tags)
249
246
  processed_messages, tripwire, modifications = runner.run(agent.session.messages)
250
247
  agent.session.set(processed_messages) unless tripwire
251
248
  record_modifications!(stream_yielder, all_modifications, modifications)
@@ -253,12 +250,12 @@ module Riffer::Agent::Run
253
250
  end
254
251
 
255
252
  #--
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)
253
+ #: (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], ?Hash[String, String]) { (Riffer::Guardrails::Tripwire) -> void } -> untyped
254
+ def run_after_guardrails(agent, response, stream_yielder, all_modifications, tags = {})
258
255
  guardrails = agent.config.guardrails_for(:after)
259
256
  return response if guardrails.empty?
260
257
 
261
- runner = Riffer::Guardrails::Runner.new(guardrails, phase: :after, context: agent.context)
258
+ runner = Riffer::Guardrails::Runner.new(guardrails, phase: :after, context: agent.context, tags: tags)
262
259
  processed_response, tripwire, modifications = runner.run(response, messages: agent.session.messages)
263
260
 
264
261
  response_index = agent.session.messages.length
@@ -285,11 +282,16 @@ module Riffer::Agent::Run
285
282
  discovered.empty? ? agent.tools : agent.tools + discovered
286
283
  end
287
284
 
285
+ # +tags+ rides in the options hash as a curated key the providers extract for
286
+ # native request-metadata mapping (alongside +:structured_output+); it never
287
+ # reaches an SDK call verbatim. Span tagging is threaded separately to each
288
+ # builder.
288
289
  #--
289
- #: (Riffer::Agent) -> Hash[Symbol, untyped]
290
- def merged_model_options(agent)
290
+ #: (Riffer::Agent, ?Hash[String, String]) -> Hash[Symbol, untyped]
291
+ def merged_model_options(agent, tags = {})
291
292
  opts = agent.config.model_options.dup
292
293
  opts[:structured_output] = agent.structured_output if agent.structured_output
294
+ opts[:tags] = tags unless tags.empty?
293
295
  opts
294
296
  end
295
297
 
@@ -329,27 +331,34 @@ module Riffer::Agent::Run
329
331
  end
330
332
 
331
333
  #--
332
- #: (Riffer::Agent) -> Hash[String, untyped]
333
- def run_span_attributes(agent)
334
+ #: (Riffer::Agent, ?Hash[String, String]) -> Hash[String, untyped]
335
+ def run_span_attributes(agent, tags = {})
334
336
  {
335
337
  "gen_ai.operation.name" => "invoke_agent",
336
338
  "gen_ai.agent.name" => agent.class.identifier,
337
339
  "gen_ai.provider.name" => agent.provider.class.semconv_provider_name,
338
340
  "gen_ai.request.model" => agent.model_name
339
- }
341
+ }.merge(tag_attributes(tags))
340
342
  end
341
343
 
342
344
  #--
343
- #: (Riffer::Agent, String?) -> Hash[String, untyped]
344
- def run_metric_attributes(agent, error_type)
345
- attributes = {
346
- "gen_ai.operation.name" => "invoke_agent",
347
- "gen_ai.provider.name" => agent.provider.class.semconv_provider_name,
348
- "gen_ai.request.model" => agent.model_name,
349
- "gen_ai.agent.name" => agent.class.identifier
350
- } #: Hash[String, untyped]
351
- attributes["error.type"] = error_type if error_type
352
- attributes
345
+ #: (Hash[(String | Symbol), untyped]?) -> Hash[String, String]
346
+ def normalize_tags(tags)
347
+ return {} if tags.nil?
348
+ raise Riffer::ArgumentError, "tags: must be a Hash, got #{tags.class}" unless tags.is_a?(Hash)
349
+
350
+ result = {} #: Hash[String, String]
351
+ tags.each do |key, value|
352
+ next if value.nil?
353
+ result[key.to_s] = value.to_s
354
+ end
355
+ result
356
+ end
357
+
358
+ #--
359
+ #: (Hash[String, String]) -> Hash[String, String]
360
+ def tag_attributes(tags)
361
+ tags.transform_keys { |key| "riffer.tag.#{key}" }
353
362
  end
354
363
 
355
364
  #--
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 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
data/lib/riffer/config.rb CHANGED
@@ -106,47 +106,6 @@ class Riffer::Config
106
106
  end
107
107
  end
108
108
 
109
- # Metrics-related global configuration, independent of +config.tracing+ so a
110
- # host can run one signal without the other.
111
- class Metrics
112
- # Whether riffer records OTEL metric instruments; defaults to +true+, a
113
- # no-op until a host wires an OTEL metrics SDK.
114
- attr_reader :enabled #: bool
115
-
116
- # The backend riffer routes measurements through; defaults to +nil+, a no-op.
117
- # Riffer auto-detects no backend; assigning one is opt-in.
118
- attr_reader :backend #: untyped
119
-
120
- #--
121
- #: () -> void
122
- def initialize
123
- @enabled = true
124
- @backend = nil
125
- end
126
-
127
- # Sets the enabled flag, coercing boolean-ish values so an env-var
128
- # +"false"+ (truthy in Ruby) doesn't silently keep metrics on. Raises
129
- # Riffer::ArgumentError on an unrecognized value.
130
- #--
131
- #: (untyped) -> void
132
- def enabled=(value)
133
- @enabled = Riffer::Helpers::Boolean.coerce(value, attribute: "enabled")
134
- end
135
-
136
- # Sets the metrics backend riffer routes measurements through. Raises
137
- # Riffer::ArgumentError unless the value is +nil+ or responds to
138
- # +record_histogram+.
139
- #--
140
- #: (untyped) -> void
141
- def backend=(value)
142
- unless value.nil? || value.respond_to?(:record_histogram)
143
- raise Riffer::ArgumentError, "metrics backend must respond to #record_histogram"
144
- end
145
- @backend = value
146
- Riffer::Metrics.reset!
147
- end
148
- end
149
-
150
109
  # Consumer-configured token pricing, keyed by +provider/model+ id. Riffer
151
110
  # ships no price table, so an unconfigured model carries no cost.
152
111
  class Pricing
@@ -306,9 +265,6 @@ class Riffer::Config
306
265
  # Tracing-related global configuration.
307
266
  attr_reader :tracing #: Riffer::Config::Tracing
308
267
 
309
- # Metrics-related global configuration.
310
- attr_reader :metrics #: Riffer::Config::Metrics
311
-
312
268
  # Consumer-configured per-model token pricing.
313
269
  attr_reader :pricing #: Riffer::Config::Pricing
314
270
 
@@ -357,7 +313,6 @@ class Riffer::Config
357
313
  @tool_runtime = Riffer::Tools::Runtime::Inline.new
358
314
  @skills = Skills.new
359
315
  @tracing = Tracing.new
360
- @metrics = Metrics.new
361
316
  @pricing = Pricing.new
362
317
  @message_id_strategy = :none
363
318
  @experimental_history_healing = false
@@ -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
@@ -81,32 +85,15 @@ class Riffer::Guardrails::Runner
81
85
  #--
82
86
  #: (Riffer::Guardrail, untyped, messages: Array[Riffer::Messages::Base]?) -> Riffer::Guardrails::Result
83
87
  def execute_guardrail(guardrail, data, messages:)
84
- instrument_guardrail(guardrail) do
85
- Riffer::Tracing.in_span("execute_guardrail #{guardrail.name}", attributes: guardrail_span_attributes(guardrail), kind: :internal) do |span|
86
- result = run_guardrail_phase(guardrail, data, messages: messages)
87
- record_guardrail_outcome(span, result)
88
- result
89
- rescue => error
90
- # The backend records the exception and error status on the re-raise;
91
- # error.type is the one semconv attribute it doesn't set.
92
- span.set_attribute("error.type", error.class.name)
93
- raise
94
- end
95
- end
96
- end
97
-
98
- #--
99
- #: (Riffer::Guardrail) { () -> Riffer::Guardrails::Result } -> Riffer::Guardrails::Result
100
- def instrument_guardrail(guardrail)
101
- start = Riffer::Metrics.monotonic_now
102
- error_type = nil #: String?
103
- begin
104
- yield
88
+ Riffer::Tracing.in_span("execute_guardrail #{guardrail.name}", attributes: guardrail_span_attributes(guardrail), kind: :internal) do |span|
89
+ result = run_guardrail_phase(guardrail, data, messages: messages)
90
+ record_guardrail_outcome(span, result)
91
+ result
105
92
  rescue => error
106
- error_type = error.class.name #: String?
93
+ # The backend records the exception and error status on the re-raise;
94
+ # error.type is the one semconv attribute it doesn't set.
95
+ span.set_attribute("error.type", error.class.name)
107
96
  raise
108
- ensure
109
- Riffer::Metrics::Instruments::GUARDRAIL_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: guardrail_metric_attributes(guardrail, error_type))
110
97
  end
111
98
  end
112
99
 
@@ -129,15 +116,7 @@ class Riffer::Guardrails::Runner
129
116
  {
130
117
  "riffer.guardrail.name" => guardrail.name,
131
118
  "riffer.guardrail.phase" => phase.to_s
132
- }
133
- end
134
-
135
- #--
136
- #: (Riffer::Guardrail, String?) -> Hash[String, untyped]
137
- def guardrail_metric_attributes(guardrail, error_type)
138
- attributes = guardrail_span_attributes(guardrail)
139
- attributes["error.type"] = error_type if error_type
140
- attributes
119
+ }.merge(tags.transform_keys { |key| "riffer.tag.#{key}" })
141
120
  end
142
121
 
143
122
  # A block is a handled outcome, so its span status stays unset — an error